@bli-cockpit/cli 0.2.265 → 0.2.267

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.
@@ -126,7 +126,8 @@ export async function readRegisteredDarwinRuntimePaths(homeDir) {
126
126
  const command = decodeXmlEntities(plist);
127
127
  return {
128
128
  node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
129
- cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
129
+ cli_entry_point: /"\$node_bin"(?: --optimize-for-size)? '([^']*)' sync /u.exec(command)?.[1] ?? null,
130
+ optimize_for_size: /"\$node_bin" '[^']*' sync /u.test(command) ? false : /"\$node_bin" --optimize-for-size '[^']*' sync /u.test(command) ? true : null,
130
131
  };
131
132
  }
132
133
  /**
@@ -222,12 +223,15 @@ function watchPathsBlock(watchPaths) {
222
223
  " </array>",
223
224
  ];
224
225
  }
226
+ /** The V8 flag the scheduled sync runs node with (BLI-23317). */
227
+ export const NODE_OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
225
228
  function renderDarwinSyncCommand(options) {
229
+ const nodeFlag = options.optimizeForSize === false ? "" : ` ${NODE_OPTIMIZE_FOR_SIZE_FLAG}`;
226
230
  const dashboardArg = isDefaultDashboardUrl(options.dashboardUrl)
227
231
  ? ""
228
232
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
229
233
  const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
230
- const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
234
+ const commands = options.workDirs.map((root) => `"$node_bin"${nodeFlag} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
231
235
  return [
232
236
  "exit_code=0",
233
237
  ...darwinNodeResolutionPreamble(options.nodeExecutable),
@@ -278,14 +282,16 @@ function darwinAgentRegistrationProblems(plist, expected) {
278
282
  if (!plist.includes(` <key>ThrottleInterval</key>\n <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`)) {
279
283
  problems.push(`minimum gap between ticks is not ${DARWIN_MIN_SECONDS_BETWEEN_TICKS} seconds`);
280
284
  }
281
- const expectedCommand = renderDarwinSyncCommand({
285
+ // BLI-23317: the form with --optimize-for-size is what is written; the form without it is still accepted.
286
+ const matchesCurrentRuntime = [true, false].some((optimizeForSize) => plist.includes(programArgumentsBlock(renderDarwinSyncCommand({
282
287
  discoveryArgs: expected.discoveryArgs,
283
288
  workDirs: expected.workDirs,
284
289
  dashboardUrl: expected.dashboardUrl,
285
290
  nodeExecutable: expected.nodeExecutable,
286
291
  cliEntryPoint: expected.cliEntryPoint,
287
- });
288
- if (!plist.includes(programArgumentsBlock(expectedCommand).join("\n"))) {
292
+ optimizeForSize,
293
+ })).join("\n")));
294
+ if (!matchesCurrentRuntime) {
289
295
  problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
290
296
  }
291
297
  return problems;
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
4
+ import { autostartStatus, installAutostartAgent, registeredLauncherUpgradeProblems, registeredRuntimePathProblems, } from "./autostart.js";
5
5
  import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
6
6
  import { isAutostartOptedOut } from "./autostart-opt-out.js";
7
7
  export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
@@ -52,6 +52,14 @@ async function readAutostartProblem(paths, options, platform) {
52
52
  platform,
53
53
  })
54
54
  : [];
55
+ // BLI-23317: an install that still runs node without --optimize-for-size works, so it is not "broken";
56
+ // it is rewritten once here, on the normal cadence, and logged as its own reason.
57
+ const upgradeProblems = status.status === "loaded"
58
+ ? await registeredLauncherUpgradeProblems({ homeDir: options.homeDir, exec: options.exec, platform })
59
+ : [];
60
+ if (upgradeProblems.length > 0) {
61
+ console.error("[autostart-self-heal] registration runs node without --optimize-for-size; rewriting it", JSON.stringify({ reason: "registration_predates_optimize_for_size", platform }));
62
+ }
55
63
  const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
56
64
  // BLI-21564: the reminder job is repaired with the sync agent. A missing or broken reminder job is a
57
65
  // problem of its own (an existing install that predates it gets it created here), and the opt-out
@@ -59,7 +67,7 @@ async function readAutostartProblem(paths, options, platform) {
59
67
  const remindProblem = status.status === "loaded" && status.remind && status.remind.status !== "loaded" && status.remind.status !== "unsupported"
60
68
  ? [`reminder job ${status.remind.status === "absent" ? "absent" : status.remind.message ?? "not loaded"}`]
61
69
  : [];
62
- if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && remindProblem.length === 0) {
70
+ if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && upgradeProblems.length === 0 && remindProblem.length === 0) {
63
71
  // Healthy is the steady state and stays silent. A marker left by an
64
72
  // earlier attempt is spent: that repair worked, so the next break starts
65
73
  // fresh instead of waiting out a retry window.
@@ -70,6 +78,7 @@ async function readAutostartProblem(paths, options, platform) {
70
78
  ...(absent ? ["registration absent"] : []),
71
79
  ...(status.message ? [status.message] : []),
72
80
  ...runtimeProblems,
81
+ ...upgradeProblems,
73
82
  ...remindProblem,
74
83
  ]
75
84
  .join("; ")
@@ -110,6 +110,9 @@ export async function readRegisteredWindowsRuntimePaths(homeDir) {
110
110
  return {
111
111
  node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
112
112
  cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
113
+ optimize_for_size: /& \$nodeExecutable --optimize-for-size \$cliEntryPoint sync /u.test(script)
114
+ ? true
115
+ : /& \$nodeExecutable \$cliEntryPoint sync /u.test(script) ? false : null,
113
116
  };
114
117
  }
115
118
  /**
@@ -125,15 +128,22 @@ export async function syncScriptProblem(options) {
125
128
  if (!options.repoRoots || options.repoRoots.length === 0)
126
129
  return null;
127
130
  const settings = resolveAutostartSettings(options, "win32");
128
- const expected = `${UTF8_BOM}${renderWindowsSyncScript({
131
+ const onDisk = await readFile(scriptPath, "utf8").catch(() => null);
132
+ const expectedFor = async (optimizeForSize) => `${UTF8_BOM}${renderWindowsSyncScript({
129
133
  discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
130
134
  workDirs: settings.work_dirs,
131
135
  dashboardUrl: settings.dashboard_url,
132
136
  nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
133
137
  cliEntryPoint: settings.cli_entry_point,
134
138
  syncLogPath: windowsSyncLogPath(options.homeDir),
139
+ optimizeForSize,
135
140
  })}`;
136
- const difference = renderedFileDifference(await readFile(scriptPath, "utf8").catch(() => null), expected);
141
+ // BLI-23317: the script with --optimize-for-size is what is written; the older one without it is still accepted.
142
+ const difference = renderedFileDifference(onDisk, await expectedFor(true)) === null
143
+ ? null
144
+ : renderedFileDifference(onDisk, await expectedFor(false)) === null
145
+ ? null
146
+ : renderedFileDifference(onDisk, await expectedFor(true));
137
147
  return difference
138
148
  ? `sync script does not match the current roots or Tower runtime (${describeDifference(difference)})`
139
149
  : null;
@@ -271,13 +281,14 @@ export function renderWindowsLauncherScript(options) {
271
281
  ].join("\r\n");
272
282
  }
273
283
  function renderWindowsSyncScript(options) {
284
+ const nodeFlag = options.optimizeForSize === false ? "" : " --optimize-for-size";
274
285
  const dashboardArgs = isDefaultDashboardUrl(options.dashboardUrl)
275
286
  ? ""
276
287
  : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
277
288
  const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
278
289
  const commands = options.workDirs.flatMap((root) => [
279
290
  "try {",
280
- ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
291
+ ` & $nodeExecutable${nodeFlag} $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
281
292
  " if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
282
293
  "} catch {",
283
294
  " Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
package/dist/autostart.js CHANGED
@@ -75,7 +75,20 @@ export async function readRegisteredRuntimePaths(options) {
75
75
  if (platform === "win32") {
76
76
  return readRegisteredWindowsRuntimePaths(options.homeDir);
77
77
  }
78
- return { node_executable: null, cli_entry_point: null };
78
+ return { node_executable: null, cli_entry_point: null, optimize_for_size: null };
79
+ }
80
+ /**
81
+ * BLI-23317: a registration written before `--optimize-for-size` still RUNS, so it is not a broken
82
+ * registration and `registeredRuntimePathProblems` stays silent about it. The scheduled tick's
83
+ * self-heal asks this separately so the existing fleet is rewritten to the new form on its normal
84
+ * cadence instead of waiting for a hand-run `cockpit doctor`.
85
+ */
86
+ export async function registeredLauncherUpgradeProblems(options) {
87
+ const platform = options.platform ?? process.platform;
88
+ if (platform !== (options.hostPlatform ?? process.platform))
89
+ return [];
90
+ const registered = await readRegisteredRuntimePaths(options);
91
+ return registered.optimize_for_size === false ? ["registration predates --optimize-for-size"] : [];
79
92
  }
80
93
  /**
81
94
  * Named problems with the registered runtime, or an empty list. Path-free
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // BLI-23317: first import, so the V8 flag is set before the heavy modules below load.
3
+ import "./optimize-for-size-entry.js";
2
4
  import { runCockpitCli } from "./commands/public-root.js";
3
5
  import {
4
6
  EXIT_DIED_MID_RUN,
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.265");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.267");
25
25
  return 0;
26
26
  }
27
27
 
@@ -207,7 +207,12 @@ async function describeAndReplace(run, input, finds) {
207
207
  const deferredOnly = counts.over_budget + counts.backing_off > 0
208
208
  && counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
209
209
  const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
210
- const pendingImages = [...unique.keys()].filter((sha256) => !described.has(imageStateKey(sha256)));
210
+ // BLI-23317: an image over the describe limit is never sent, so it can never gain a description and
211
+ // never changes the output. Listing it made the describer look like it still had room, and the
212
+ // transcript was rebuilt on every tick (rebuilt_describer_has_room). Counted in `too_large` above.
213
+ const pendingImages = [...unique.values()]
214
+ .filter((image) => !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
215
+ .map((image) => image.sha256);
211
216
  console.error(TAG + " transcript prepared", JSON.stringify({
212
217
  mode,
213
218
  source: input.source,
@@ -2,87 +2,32 @@
2
2
  * Which images this machine has already sent to the describe door (BLI-16977).
3
3
  *
4
4
  * One small file in the collector state directory. It is how "each distinct image is sent once"
5
- * survives from one 15-minute sync to the next. A key is the first 32 hex characters of the
6
- * image's sha256 (128 bits: no two images on one laptop will ever share one); the file holds keys
7
- * only, never a description, never a path, never a byte. If it is lost or corrupt the worst case
8
- * is the images are sent again and the server answers "known" without calling a model.
5
+ * survives from one 15-minute sync to the next. The file holds keys only, never a description, never
6
+ * a path, never a byte. If it is lost or corrupt the worst case is the images are sent again and the
7
+ * server answers "known" without calling a model.
8
+ *
9
+ * BLI-23328: the BLI Memory daemon writes the same file when it describes an image at turn end, so
10
+ * the format, the keys, the backoff steps and the locked read-merge-write live in telemetry-core
11
+ * (`image-description-store.ts`), shared by both writers. This module keeps the collector's names.
9
12
  */
10
- import path from "node:path";
11
- import { isMissingFileFailure } from "./health-detail.js";
12
- import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
13
- export const IMAGE_DESCRIPTION_STATE_FILENAME = "image-descriptions.json";
14
- /** Oldest keys leave first past this many (about 5 MB on disk). */
15
- export const IMAGE_DESCRIPTION_STATE_MAX_KEYS = 150_000;
16
- const KEY_CHARS = 32;
17
- export function imageStateKey(sha256) {
18
- return sha256.slice(0, KEY_CHARS);
19
- }
20
- export function imageDescriptionStatePath(stateDir) {
21
- return path.join(stateDir, IMAGE_DESCRIPTION_STATE_FILENAME);
22
- }
13
+ import { IMAGE_DESCRIPTION_STATE_FILENAME, IMAGE_DESCRIPTION_STATE_MAX_KEYS, IMAGE_FAILED_STATE_MAX_KEYS, imageDescriptionStatePath, imageRetryDelayMs, imageStateKey, mergeImageDescriptionStates, readImageDescriptionState, updateImageDescriptionState, } from "@bli-cockpit/telemetry-core";
14
+ export { IMAGE_DESCRIPTION_STATE_FILENAME, IMAGE_DESCRIPTION_STATE_MAX_KEYS, IMAGE_FAILED_STATE_MAX_KEYS, imageDescriptionStatePath, imageRetryDelayMs, imageStateKey, };
23
15
  /** The keys already described, oldest first. A missing or unreadable file is an empty list, said out loud when unreadable. */
24
16
  export async function readDescribedImageKeys(stateDir) {
25
- try {
26
- const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
27
- if (!Array.isArray(raw?.described))
28
- return [];
29
- return raw.described.filter((key) => typeof key === "string" && key.length === KEY_CHARS);
30
- }
31
- catch (error) {
32
- if (!isMissingFileFailure(error)) {
33
- console.error("[image-descriptions] state file unreadable, starting from an empty list", JSON.stringify({ reason: "image_state_unreadable" }));
34
- }
35
- return [];
36
- }
37
- }
38
- /** Oldest failures leave first past this many. */
39
- export const IMAGE_FAILED_STATE_MAX_KEYS = 5_000;
40
- const HOUR_MS = 3_600_000;
41
- /** A refusal that repeats for the same bytes (a 4xx): a week, then a month. */
42
- const PERMANENT_BACKOFF_MS = [7 * 24 * HOUR_MS, 30 * 24 * HOUR_MS];
43
- /** A failure that may pass (the door or the model was down): 1 h, 6 h, 24 h, then 72 h. */
44
- const TRANSIENT_BACKOFF_MS = [HOUR_MS, 6 * HOUR_MS, 24 * HOUR_MS, 72 * HOUR_MS];
45
- /** How long to leave an image alone after its Nth failed attempt. */
46
- export function imageRetryDelayMs(attempts, permanent) {
47
- const steps = permanent ? PERMANENT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
48
- return steps[Math.min(Math.max(attempts, 1), steps.length) - 1];
17
+ return (await readImageDescriptionState(stateDir)).described;
49
18
  }
50
19
  /** The failures on record, key to record. A missing or unreadable file is an empty map, said out loud when unreadable. */
51
20
  export async function readFailedImages(stateDir) {
52
- try {
53
- const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
54
- if (!raw?.failed || typeof raw.failed !== "object" || Array.isArray(raw.failed))
55
- return {};
56
- const kept = {};
57
- for (const [key, value] of Object.entries(raw.failed)) {
58
- const record = value;
59
- if (key.length !== KEY_CHARS || !record)
60
- continue;
61
- if (typeof record.reason !== "string" || typeof record.attempts !== "number" || typeof record.retry_after !== "number")
62
- continue;
63
- kept[key] = { reason: record.reason, attempts: record.attempts, retry_after: record.retry_after };
64
- }
65
- return kept;
66
- }
67
- catch (error) {
68
- if (!isMissingFileFailure(error)) {
69
- console.error("[image-descriptions] failed-image list unreadable, starting from an empty list", JSON.stringify({ reason: "image_failed_state_unreadable" }));
70
- }
71
- return {};
72
- }
21
+ return (await readImageDescriptionState(stateDir)).failed;
73
22
  }
23
+ /**
24
+ * Write this sync's view, merged with whatever the file holds now: the memory daemon may have
25
+ * described images since the tick opened its copy, and those entries must survive.
26
+ */
74
27
  export async function writeImageDescriptionState(stateDir, state) {
75
- const keys = state.described;
76
- const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
77
- const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
78
- const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
79
- await writeJsonFileAtomic(imageDescriptionStatePath(stateDir), {
80
- schema_version: "cockpit-image-descriptions.v1",
81
- described: kept,
82
- failed,
83
- });
28
+ await updateImageDescriptionState(stateDir, (disk) => mergeImageDescriptionStates({ described: [...state.described], failed: { ...state.failed } }, disk));
84
29
  }
85
30
  /** Writes the described list and keeps whatever failures are on record. */
86
31
  export async function writeDescribedImageKeys(stateDir, keys) {
87
- await writeImageDescriptionState(stateDir, { described: keys, failed: await readFailedImages(stateDir) });
32
+ await writeImageDescriptionState(stateDir, { described: keys, failed: {} });
88
33
  }
@@ -0,0 +1,3 @@
1
+ import { applyOptimizeForSize } from "./optimize-for-size.js";
2
+ // Side-effect module: imported FIRST by the CLI entry so the flag is set before the heavy imports load (BLI-23317).
3
+ applyOptimizeForSize();
@@ -0,0 +1,25 @@
1
+ import v8 from "node:v8";
2
+ /**
3
+ * BLI-23317: run V8 with `--optimize-for-size`. Measured 15-30% lower peak memory with no slowdown on
4
+ * Node 20-26 (one 200 MB transcript 848 -> 721 MB; CLI floor 113 -> 94 MB). `NODE_OPTIONS` rejects the
5
+ * flag, so the scheduled launchers pass it on the node command line and a hand-run `cockpit` applies it
6
+ * here, as the first statement of the entry (`optimize-for-size-entry.ts`, imported before anything heavy).
7
+ *
8
+ * Only a refusal is logged: this runs at the start of every command a person types, and a stderr line
9
+ * on each success would be noise in their terminal. A refusal is logged and ignored, never fatal: the flag is a memory saving, not a requirement.
10
+ */
11
+ export const OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
12
+ export function applyOptimizeForSize(options = {}) {
13
+ const execArgv = options.execArgv ?? process.execArgv;
14
+ if (execArgv.includes(OPTIMIZE_FOR_SIZE_FLAG)) {
15
+ return "already_set_by_launcher";
16
+ }
17
+ try {
18
+ (options.setFlags ?? v8.setFlagsFromString)(OPTIMIZE_FOR_SIZE_FLAG);
19
+ return "applied";
20
+ }
21
+ catch (error) {
22
+ console.error("[optimize-for-size] could not apply the flag; continuing without it", JSON.stringify({ reason: "set_flags_refused", error: error instanceof Error ? error.name : "unknown" }));
23
+ return "refused";
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.265",
3
+ "version": "0.2.267",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
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-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.78",
31
- "@bli-cockpit/mcp": "0.1.162",
32
- "@bli-cockpit/telemetry-core": "0.1.85"
30
+ "@bli-cockpit/memory-mcp": "0.1.79",
31
+ "@bli-cockpit/mcp": "0.1.163",
32
+ "@bli-cockpit/telemetry-core": "0.1.86"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",