@bli-cockpit/cli 0.2.47 → 0.2.48
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.
|
@@ -25,6 +25,7 @@ import os from "node:os";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
27
27
|
import { describeError } from "../health-detail.js";
|
|
28
|
+
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
28
29
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
29
30
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
30
31
|
/**
|
|
@@ -112,6 +113,23 @@ export function buildCollectorHeartbeat(options) {
|
|
|
112
113
|
* `sync.err.log` alone when the dashboard says a device is quiet.
|
|
113
114
|
*/
|
|
114
115
|
export async function sendCollectorHeartbeatBestEffort(options) {
|
|
116
|
+
// BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
|
|
117
|
+
// device row, so an agent's worktree could keep a genuinely dead machine
|
|
118
|
+
// looking alive to the BLI-3550 reader.
|
|
119
|
+
const suppression = shouldSuppressFleetReceipts({
|
|
120
|
+
dashboardUrl: options.dashboardUrl,
|
|
121
|
+
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
122
|
+
});
|
|
123
|
+
if (suppression.suppressed) {
|
|
124
|
+
console.error("[heartbeat] dev build; this tick is not checking in with the fleet", JSON.stringify({
|
|
125
|
+
reason: "receipt_suppressed:dev_build",
|
|
126
|
+
detected_by: suppression.reason,
|
|
127
|
+
root_count: options.roots.length,
|
|
128
|
+
sync_status: options.facts.status,
|
|
129
|
+
fix: "set COCKPIT_DEV=0 to heartbeat from a checkout deliberately",
|
|
130
|
+
}));
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
115
133
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
116
134
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
117
135
|
if (!session ||
|
|
@@ -14,6 +14,7 @@ import { errorMessage, writeLine } from "./cli-io.js";
|
|
|
14
14
|
import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
|
|
15
15
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
16
16
|
import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
|
|
17
|
+
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
17
18
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
18
19
|
export function addInstallEvent(events, step, status, errorCode,
|
|
19
20
|
// BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
|
|
@@ -47,6 +48,25 @@ export function sanitizeInstallErrorCode(value) {
|
|
|
47
48
|
export async function reportInstallEventsBestEffort(options) {
|
|
48
49
|
if (options.events.length === 0)
|
|
49
50
|
return null;
|
|
51
|
+
// BLI-3554. Withheld BEFORE the outbox, not before the POST: an entry queued
|
|
52
|
+
// by a checkout survives in `~/.cockpit` and the next real scheduled tick
|
|
53
|
+
// would deliver it under the workspace version, which is exactly how 47
|
|
54
|
+
// sandbox receipts reached the production fleet table.
|
|
55
|
+
const suppression = shouldSuppressFleetReceipts({
|
|
56
|
+
dashboardUrl: options.dashboardUrl,
|
|
57
|
+
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (suppression.suppressed) {
|
|
60
|
+
console.error("[install-receipts] dev build; install receipts withheld from the fleet", JSON.stringify({
|
|
61
|
+
reason: "receipt_suppressed:dev_build",
|
|
62
|
+
detected_by: suppression.reason,
|
|
63
|
+
command: options.command,
|
|
64
|
+
event_count: options.events.length,
|
|
65
|
+
cli_version: LOCAL_COLLECTOR_VERSION,
|
|
66
|
+
fix: "set COCKPIT_DEV=0 to post receipts from a checkout deliberately",
|
|
67
|
+
}));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
50
70
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
51
71
|
try {
|
|
52
72
|
await enqueueInstallEventEntry(paths, {
|
|
@@ -98,6 +118,7 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
98
118
|
}
|
|
99
119
|
const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
|
|
100
120
|
const failures = [];
|
|
121
|
+
let delivered = 0;
|
|
101
122
|
let observedMinCliVersion = null;
|
|
102
123
|
for (let offset = 0; offset < pending.length; offset += 5) {
|
|
103
124
|
await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
|
|
@@ -138,6 +159,7 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
138
159
|
observedMinCliVersion = receipt.min_cli_version.trim();
|
|
139
160
|
}
|
|
140
161
|
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
162
|
+
delivered += 1;
|
|
141
163
|
}
|
|
142
164
|
catch (error) {
|
|
143
165
|
const failureReason = classifyInstallTelemetryError(error);
|
|
@@ -170,6 +192,18 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
170
192
|
}
|
|
171
193
|
}));
|
|
172
194
|
}
|
|
195
|
+
if (delivered > 0) {
|
|
196
|
+
// The success branch says so too (BLI-3554 / the logging contract): a log
|
|
197
|
+
// that only fires on failure cannot answer "did any receipt land at all
|
|
198
|
+
// today?", which is the question a suppressed dev build now provokes.
|
|
199
|
+
console.error("[install-receipts] install events delivered", JSON.stringify({
|
|
200
|
+
reason: "install_events_delivered",
|
|
201
|
+
command: options.command,
|
|
202
|
+
delivered_entries: delivered,
|
|
203
|
+
kept_for_retry: pending.length - delivered,
|
|
204
|
+
cli_version: LOCAL_COLLECTOR_VERSION,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
173
207
|
if (options.json && failures.length > 0) {
|
|
174
208
|
writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
|
|
175
209
|
}
|
|
@@ -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.48");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this collector a DEV BUILD? One predicate, two callers.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3554. Edward's device row carried 47 `npm_install_eacces` receipts
|
|
5
|
+
* stamped `cli_version` 0.1.50 between 2026-09-02T16:55Z and 2026-09-03T06:25Z.
|
|
6
|
+
* 0.1.50 was never a published install on that machine — it was the workspace
|
|
7
|
+
* version in `packages/cockpit-local-collector/package.json` at the time, so
|
|
8
|
+
* every one of those rows came from an agent or a test running the built
|
|
9
|
+
* collector inside a worktree. They land in the same production
|
|
10
|
+
* `cockpit_install_events` table the fleet reader (BLI-3550,
|
|
11
|
+
* `apps/dashboard/src/lib/ops/fleet-liveness.ts`) alarms on, so a developer's
|
|
12
|
+
* sandbox failure reads as a fleet machine in trouble.
|
|
13
|
+
*
|
|
14
|
+
* The predicate is deliberately NOT a version comparison. A version equal to
|
|
15
|
+
* the workspace version is a symptom: it changes on every release, it is equal
|
|
16
|
+
* for the one machine that legitimately runs the newest published build the
|
|
17
|
+
* day it ships, and it says nothing about where the code was loaded from.
|
|
18
|
+
* What we can actually observe is the FILE the running code was loaded from:
|
|
19
|
+
*
|
|
20
|
+
* - a published install lives under a `node_modules/` prefix
|
|
21
|
+
* (`/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/…`,
|
|
22
|
+
* `C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@bli-cockpit\\cli\\dist\\…`),
|
|
23
|
+
* and that is the whole fleet;
|
|
24
|
+
* - a dev build lives inside a checkout of this repo — a
|
|
25
|
+
* `packages/cockpit-local-collector/` (or `packages/cockpit-cli/`) ancestor
|
|
26
|
+
* whose `package.json` names the workspace, with a `.git` marker or a
|
|
27
|
+
* `.claude/worktrees` segment above it.
|
|
28
|
+
*
|
|
29
|
+
* `COCKPIT_DEV=1` forces dev. `COCKPIT_DEV=0` forces the opposite — that is the
|
|
30
|
+
* escape hatch for a deliberate live test from a checkout, documented in
|
|
31
|
+
* `docs/runbooks/cockpit-collector-receipts.md`.
|
|
32
|
+
*
|
|
33
|
+
* Suppression only bites when the target is a PRODUCTION dashboard. Pointing a
|
|
34
|
+
* checkout at `http://localhost:3000` is how the receipt path itself is
|
|
35
|
+
* developed, and refusing to post there would make this predicate the reason
|
|
36
|
+
* the next receipt bug cannot be reproduced.
|
|
37
|
+
*/
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
/** The workspaces whose `dist/` a developer or agent runs from a checkout. */
|
|
42
|
+
const WORKSPACE_PACKAGE_NAMES = new Set([
|
|
43
|
+
"@bli-cockpit/local-collector",
|
|
44
|
+
"@bli-cockpit/cli",
|
|
45
|
+
]);
|
|
46
|
+
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
47
|
+
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
|
48
|
+
/**
|
|
49
|
+
* Walks up from the running module. Pure apart from the two injected readers,
|
|
50
|
+
* so a Windows layout can be asserted from a macOS test run.
|
|
51
|
+
*/
|
|
52
|
+
export function detectDevBuild(probe = {}) {
|
|
53
|
+
const env = probe.env ?? process.env;
|
|
54
|
+
const rawFlag = (env.COCKPIT_DEV ?? "").trim().toLowerCase();
|
|
55
|
+
if (TRUE_VALUES.has(rawFlag)) {
|
|
56
|
+
return { devBuild: true, reason: "cockpit_dev_env" };
|
|
57
|
+
}
|
|
58
|
+
if (FALSE_VALUES.has(rawFlag)) {
|
|
59
|
+
// Deliberate live test from a checkout: the operator has said so out loud,
|
|
60
|
+
// and the path evidence below is not allowed to overrule them.
|
|
61
|
+
return { devBuild: false, reason: "cockpit_dev_env_disabled" };
|
|
62
|
+
}
|
|
63
|
+
const pathApi = probe.pathApi ?? path;
|
|
64
|
+
const modulePath = probe.modulePath ?? currentModulePath();
|
|
65
|
+
if (!modulePath)
|
|
66
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
67
|
+
if (hasNodeModulesSegment(modulePath)) {
|
|
68
|
+
// Every published install is under a `node_modules/` prefix, on both host
|
|
69
|
+
// families. This branch is the whole fleet, and it is checked first so a
|
|
70
|
+
// machine that happens to have a repo checkout somewhere above its global
|
|
71
|
+
// npm prefix cannot be mislabelled.
|
|
72
|
+
return { devBuild: false, reason: "published_install" };
|
|
73
|
+
}
|
|
74
|
+
const readPackageName = probe.readPackageName ?? defaultReadPackageName;
|
|
75
|
+
const pathExists = probe.pathExists ?? defaultPathExists;
|
|
76
|
+
const packageRoot = findWorkspacePackageRoot(pathApi.dirname(modulePath), pathApi, readPackageName);
|
|
77
|
+
if (!packageRoot)
|
|
78
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
79
|
+
if (!hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists)) {
|
|
80
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
81
|
+
}
|
|
82
|
+
return { devBuild: true, reason: "workspace_checkout" };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Should this process withhold fleet receipts (install events, heartbeat)?
|
|
86
|
+
*
|
|
87
|
+
* Two facts, in this order: is the code a dev build, and is the target the
|
|
88
|
+
* production fleet. A dev build talking to `localhost` still posts, because
|
|
89
|
+
* that is the only way to exercise the receipt path at all.
|
|
90
|
+
*/
|
|
91
|
+
export function shouldSuppressFleetReceipts(options) {
|
|
92
|
+
const verdict = detectDevBuild(options.probe ?? {});
|
|
93
|
+
if (!verdict.devBuild)
|
|
94
|
+
return { suppressed: false, reason: verdict.reason };
|
|
95
|
+
if (isLocalDashboardUrl(options.dashboardUrl)) {
|
|
96
|
+
return { suppressed: false, reason: "local_dashboard" };
|
|
97
|
+
}
|
|
98
|
+
return { suppressed: true, reason: verdict.reason };
|
|
99
|
+
}
|
|
100
|
+
/** A dashboard on this machine — safe for a checkout to post at. */
|
|
101
|
+
export function isLocalDashboardUrl(dashboardUrl) {
|
|
102
|
+
let hostname;
|
|
103
|
+
try {
|
|
104
|
+
hostname = new URL(dashboardUrl).hostname.toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// An unparseable URL is not demonstrably local, and this predicate only
|
|
108
|
+
// ever widens suppression when it says false.
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const bare = hostname.replace(/^\[|\]$/gu, "");
|
|
112
|
+
return (bare === "localhost" ||
|
|
113
|
+
bare === "127.0.0.1" ||
|
|
114
|
+
bare === "::1" ||
|
|
115
|
+
bare === "0.0.0.0" ||
|
|
116
|
+
bare.endsWith(".localhost"));
|
|
117
|
+
}
|
|
118
|
+
function currentModulePath() {
|
|
119
|
+
try {
|
|
120
|
+
return fileURLToPath(import.meta.url);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Case-insensitive on purpose: Windows spells the same folder
|
|
128
|
+
* `Node_Modules` without complaint, and both separators appear on that host
|
|
129
|
+
* because a POSIX-style path survives most Node APIs there.
|
|
130
|
+
*/
|
|
131
|
+
function hasNodeModulesSegment(modulePath) {
|
|
132
|
+
return modulePath
|
|
133
|
+
.split(/[\\/]+/u)
|
|
134
|
+
.some((segment) => segment.toLowerCase() === "node_modules");
|
|
135
|
+
}
|
|
136
|
+
function findWorkspacePackageRoot(startDir, pathApi, readPackageName) {
|
|
137
|
+
let dir = startDir;
|
|
138
|
+
// Bounded: `path.dirname` reaches a fixed point at `/` or `C:\`, and the
|
|
139
|
+
// depth guard keeps a pathological symlink loop from spinning a sync walk.
|
|
140
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
141
|
+
const name = readPackageName(pathApi.join(dir, "package.json"));
|
|
142
|
+
if (name && WORKSPACE_PACKAGE_NAMES.has(name))
|
|
143
|
+
return dir;
|
|
144
|
+
const parent = pathApi.dirname(dir);
|
|
145
|
+
if (parent === dir)
|
|
146
|
+
return null;
|
|
147
|
+
dir = parent;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A repo checkout marker at or above the workspace package: `.git` (a
|
|
153
|
+
* directory in a normal clone, a FILE in a `git worktree`), or the
|
|
154
|
+
* `.claude/worktrees` staging area agents run from.
|
|
155
|
+
*/
|
|
156
|
+
function hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists) {
|
|
157
|
+
let dir = packageRoot;
|
|
158
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
159
|
+
if (pathExists(pathApi.join(dir, ".git")))
|
|
160
|
+
return true;
|
|
161
|
+
if (pathExists(pathApi.join(dir, ".claude", "worktrees")))
|
|
162
|
+
return true;
|
|
163
|
+
const parent = pathApi.dirname(dir);
|
|
164
|
+
if (parent === dir)
|
|
165
|
+
return false;
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
function defaultReadPackageName(packageJsonPath) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
173
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function defaultPathExists(candidate) {
|
|
180
|
+
try {
|
|
181
|
+
return fs.existsSync(candidate);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.48",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"pretypecheck": "npm run build",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "npm run build",
|
|
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-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-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/telemetry-core": "0.1.26"
|