@bli-cockpit/cli 0.2.98 → 0.2.100

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.
Files changed (34) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/commands/backfill-checkpoint.js +3 -1
  3. package/dist/commands/backfill-issues.js +8 -55
  4. package/dist/commands/backfill-report.js +22 -6
  5. package/dist/commands/backfill-scan.js +2 -1
  6. package/dist/commands/backfill-skip-policy.js +134 -0
  7. package/dist/commands/careers.js +16 -0
  8. package/dist/commands/doctor-pipeline-verdicts.js +238 -0
  9. package/dist/commands/doctor-pipeline.js +37 -107
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/local-args-tower-admin.js +17 -6
  12. package/dist/commands/local-args-tower-careers.js +20 -0
  13. package/dist/commands/local-args-tower-pages.js +23 -3
  14. package/dist/commands/local-args-tower-usage.js +8 -0
  15. package/dist/commands/local-args-tower.js +3 -1
  16. package/dist/commands/local-args.js +5 -1
  17. package/dist/commands/local-help-commands-tower.js +32 -5
  18. package/dist/commands/local-help-commands.js +11 -2
  19. package/dist/commands/local-help.js +8 -3
  20. package/dist/commands/local.js +13 -0
  21. package/dist/commands/memory-hook-counts.js +29 -8
  22. package/dist/commands/notes-file.js +8 -1
  23. package/dist/commands/notes-folders.js +35 -0
  24. package/dist/commands/notes-writes.js +74 -4
  25. package/dist/commands/notes.js +11 -3
  26. package/dist/commands/ops-render.js +5 -1
  27. package/dist/commands/public-root.js +1 -1
  28. package/dist/commands/usage.js +23 -0
  29. package/dist/crash-guard.js +167 -0
  30. package/dist/cursors/backfill-completion-marker.js +135 -0
  31. package/dist/cursors/backfill-cursor.js +18 -99
  32. package/dist/process-runner.js +39 -1
  33. package/dist/sync-lock.js +10 -1
  34. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
2
2
  import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
3
+ import { beginStage, endStage } from "../crash-guard.js";
3
4
  import { describeError } from "../health-detail.js";
4
5
  import { reportInstallEventsBestEffort } from "./install-receipts.js";
5
6
  import { persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
@@ -42,6 +43,8 @@ import { runCal } from "./cal.js";
42
43
  import { runMail } from "./mail.js";
43
44
  import { runProject } from "./project.js";
44
45
  import { runSearch } from "./search.js";
46
+ import { runCareers } from "./careers.js";
47
+ import { runUsage } from "./usage.js";
45
48
  import { parseLocalArgs } from "./local-args.js";
46
49
  // `./local.js` is the published entry point for this command surface: the
47
50
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -77,6 +80,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
77
80
  // including the scheduled one nobody types into (BLI-2362). Done here so it
78
81
  // applies to whichever command carried the flag.
79
82
  await rememberDiscoveryLimits(command);
83
+ // BLI-4110: the outermost stage name, so a crash that escapes every catch
84
+ // below still says which subcommand was running.
85
+ beginStage(`command:${command.kind}`);
80
86
  try {
81
87
  switch (command.kind) {
82
88
  case "install":
@@ -175,6 +181,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
175
181
  return await runSearch(command, io);
176
182
  case "release":
177
183
  return await runRelease(command, io);
184
+ case "careers":
185
+ return await runCareers(command, io);
186
+ case "usage":
187
+ return await runUsage(command, io);
178
188
  }
179
189
  }
180
190
  catch (error) {
@@ -192,6 +202,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
192
202
  writeLine(io.stderr, errorMessage(error));
193
203
  return 1;
194
204
  }
205
+ finally {
206
+ endStage(`command:${command.kind}`);
207
+ }
195
208
  }
196
209
  async function runDoctorLogin(command, io) {
197
210
  return runLogin({
@@ -11,13 +11,19 @@
11
11
  * rule are `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, which the
12
12
  * writing package spends too. That is the whole point of putting them there.
13
13
  *
14
- * ## Which event, and why only one
14
+ * ## Which event, and why the detail is on one of them
15
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.
16
+ * The PROMPT hook gets the full breakdown. It is the one a person waits on
17
+ * with their sentence typed, the one whose budget QA tick 18 caught it
18
+ * losing, and the one that runs on every turn — so it is the only one whose
19
+ * MISS RATE means anything as a daily number.
20
+ *
21
+ * BLI-4057 added the other two as a run count and a failure count each. Until
22
+ * then the fleet heard from one hook of three, so a SAVE hook that had
23
+ * stopped firing was invisible on every surface: `hooks ok` on the install
24
+ * receipt, 40 prompt recalls in the counts, and nothing anywhere saying that
25
+ * no memory had been written in ten days. Two numbers each is the smallest
26
+ * thing that can say "it ran" and "it ran and could not finish" apart.
21
27
  *
22
28
  * ## Absent is not zero
23
29
  *
@@ -37,8 +43,14 @@ export function readMemoryHookCounts(options) {
37
43
  const parsed = parseMemoryHookStats(raw);
38
44
  if (!parsed.ok)
39
45
  return { counts: null, reason: parsed.reason };
40
- const window = summariseMemoryHookWindow(parsed.file, "prompt", {
41
- now: options.now ?? new Date(),
46
+ const at = options.now ?? new Date();
47
+ const window = summariseMemoryHookWindow(parsed.file, "prompt", { now: at, hours: 24 });
48
+ // BLI-4057. The same rows, the same function, the other two events — said
49
+ // out loud in the module header: the file always held all three and only one
50
+ // of them ever left the machine.
51
+ const stop = summariseMemoryHookWindow(parsed.file, "stop", { now: at, hours: 24 });
52
+ const sessionStart = summariseMemoryHookWindow(parsed.file, "session-start", {
53
+ now: at,
42
54
  hours: 24,
43
55
  });
44
56
  return {
@@ -55,6 +67,15 @@ export function readMemoryHookCounts(options) {
55
67
  : {}),
56
68
  hook_skipped_trivial_24h: window.skippedTrivial,
57
69
  hook_billing_exhausted_24h: window.billingExhausted,
70
+ // A lost deadline and a refusal are ONE number for these two, unlike the
71
+ // prompt hook above: for a recall the difference decides whether to
72
+ // re-fit a budget or pay a bill, and for a save both mean the same
73
+ // thing — nothing was written. The prompt hook keeps them apart because
74
+ // it is the only one whose miss rate is read as a rate.
75
+ hook_stop_runs_24h: stop.runs,
76
+ hook_stop_failed_24h: stop.failed + stop.timeouts,
77
+ hook_session_start_runs_24h: sessionStart.runs,
78
+ hook_session_start_failed_24h: sessionStart.failed + sessionStart.timeouts,
58
79
  // Ticket 3934. Reported as the bin's LOWER bound — the smaller, true
59
80
  // claim — and only when something was measured. A machine that recorded
60
81
  // no histogram carries no key at all, which the board reads as
@@ -34,6 +34,10 @@ import { errorMessage } from "./cli-io.js";
34
34
  * courtesy check to avoid uploading something the server is certain to refuse,
35
35
  * not a second source of truth.
36
36
  */
37
+ export function noteAudioMime(name) {
38
+ const mime = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
39
+ return mime[name.split(".").at(-1)?.toLowerCase() ?? ""];
40
+ }
37
41
  export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
38
42
  /**
39
43
  * Above this, the terminal says it is working before it starts. The route's own
@@ -52,7 +56,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
52
56
  case "file_empty":
53
57
  return `There is nothing in that file: ${filePath}`;
54
58
  case "file_too_big":
55
- return "That file is too big to put in as a note keep it under 20 MB.";
59
+ return "That file is too big. Audio is limited to 4 MB; other notes to 20 MB.";
56
60
  case "looks_like_a_key_file":
57
61
  // The same rule the server's gate applies, said the same way: the name is
58
62
  // all it takes to decide, and looking inside to be sure would already be
@@ -88,6 +92,9 @@ export async function readNoteFile(filePath) {
88
92
  }
89
93
  if (size === 0)
90
94
  return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
95
+ if (noteAudioMime(fileName) && size > 4_000_000) {
96
+ return { ok: false, refusal: "file_too_big", detail: "file_too_large: audio limit is 4 MB (4,000,000 bytes)" };
97
+ }
91
98
  if (size > NOTE_FILE_MAX_BYTES) {
92
99
  return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
93
100
  }
@@ -0,0 +1,35 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
3
+ export async function runFolderCommand(command, door) {
4
+ const path = command.folder?.split("/").map(part => part.trim()).join("/");
5
+ const answer = await ask(door, {
6
+ path: "/api/notes/folders", method: command.action === "mkdir" ? "POST" : "GET",
7
+ label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS,
8
+ ...(command.action === "mkdir" ? { body: { path } } : {}),
9
+ });
10
+ if (!answer.ok)
11
+ return fail(door, answer.reason, answer.detail);
12
+ const body = answer.body;
13
+ if (command.action === "rmdir" || command.action === "rename") {
14
+ const folder = body.folders?.find(folder => folder.path === path);
15
+ if (!folder)
16
+ return fail(door, "folder_not_found", "That folder could not be found.");
17
+ const removed = await ask(door, { path: `/api/notes/folders/${encodeURIComponent(folder.id)}`, method: command.action === "rename" ? "PATCH" : "DELETE", label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS, ...(command.action === "rename" ? { body: { name: command.name } } : {}) });
18
+ if (!removed.ok)
19
+ return fail(door, removed.reason, removed.detail);
20
+ if (door.json)
21
+ return emit(door, removed.body);
22
+ writeLine(door.io.stdout, removed.body.headline);
23
+ return 0;
24
+ }
25
+ if (door.json)
26
+ return emit(door, body);
27
+ if (command.action === "mkdir")
28
+ writeLine(door.io.stdout, body.headline ?? "Folder ready.");
29
+ else
30
+ for (const folder of body.folders ?? []) {
31
+ const label = command.tree ? `${" ".repeat(folder.path.split("/").length - 1)}${folder.name}` : folder.path;
32
+ writeLine(door.io.stdout, `${label} (${folder.note_count})`);
33
+ }
34
+ return 0;
35
+ }
@@ -4,7 +4,7 @@
4
4
  * `notes.ts`, named in its header.
5
5
  */
6
6
  import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
7
- import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
7
+ import { NOTE_SLOW_UPLOAD_BYTES, noteAudioMime, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
8
8
  import { ask, emit, fail, sayUpload, errorText, TAG, READ_DEADLINE_MS } from "./notes-door.js";
9
9
  /**
10
10
  * The upload route's own ceiling is `maxDuration = 300` — reading a note is one
@@ -36,9 +36,11 @@ export async function uploadNotes(command, door) {
36
36
  writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
37
37
  }
38
38
  const form = new FormData();
39
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
39
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
40
40
  if (command.exclude)
41
41
  form.set("exclusions", command.exclude);
42
+ if (command.folder)
43
+ form.set("folder_path", command.folder);
42
44
  const answer = await ask(door, {
43
45
  path: "/api/notes/upload",
44
46
  method: "POST",
@@ -55,7 +57,14 @@ export async function uploadNotes(command, door) {
55
57
  continue;
56
58
  }
57
59
  const body = answer.body;
58
- results.push({ path: filePath, ok: body.stored === true, body });
60
+ if (command.wait && body.noteId && body.transcription?.status === "pending") {
61
+ body.transcription = await waitForTranscription(door, body.noteId);
62
+ }
63
+ if (body.transcription?.status === "failed")
64
+ worstExit = 1;
65
+ if (!door.json && body.transcription)
66
+ writeLine(door.io.stdout, `${body.noteId}: transcription ${body.transcription.status}${body.transcription.reason ? ": " + body.transcription.reason : ""}`);
67
+ results.push({ path: filePath, ok: body.stored === true && body.transcription?.status !== "failed", body });
59
68
  if (body.stored !== true)
60
69
  worstExit = 1;
61
70
  if (!door.json)
@@ -114,6 +123,8 @@ export async function pasteNote(command, door) {
114
123
  form.set("name", command.name);
115
124
  if (command.exclude)
116
125
  form.set("exclusions", command.exclude);
126
+ if (command.folder)
127
+ form.set("folder_path", command.folder);
117
128
  // Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
118
129
  // threshold, and the file path above already compares `bytes.byteLength`
119
130
  // against it; `text.length` counts UTF-16 units, so a note in any non-Latin
@@ -180,6 +191,43 @@ export async function shareNote(command, door) {
180
191
  sayUpload(door, body);
181
192
  return 0;
182
193
  }
194
+ /**
195
+ * `cockpit notes place <note-id> [--apply]` — BLI-4058.
196
+ *
197
+ * Asks Tower where a note belongs and prints the reason. Without `--apply` it
198
+ * moves nothing: knowing where a note would go and filing it there are two
199
+ * different acts, and the read-only one is the default because it is the one
200
+ * somebody types while they are still deciding.
201
+ */
202
+ export async function placeNote(command, door) {
203
+ const applying = command.apply === true;
204
+ const answer = await ask(door, {
205
+ path: "/api/notes/place",
206
+ method: "POST",
207
+ label: applying ? "notes place --apply" : "notes place",
208
+ timeoutMs: READ_DEADLINE_MS,
209
+ body: { note_id: command.noteId, apply: applying },
210
+ });
211
+ if (!answer.ok)
212
+ return fail(door, answer.reason, answer.detail);
213
+ const body = answer.body;
214
+ writeLine(door.io.stderr, `${TAG} place answered ${JSON.stringify({
215
+ note_id: command.noteId ?? null,
216
+ ok: body.ok === true,
217
+ decided_by: body.decidedBy ?? null,
218
+ applied: body.applied === true,
219
+ reason: body.reason ?? null,
220
+ })}`);
221
+ if (door.json)
222
+ return emit(door, body, body.ok === true ? 0 : 1);
223
+ writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
224
+ for (const line of body.lines ?? [])
225
+ writeLine(door.io.stdout, line);
226
+ if (body.ok === true && !applying) {
227
+ writeLine(door.io.stdout, "Nothing moved. Add --apply to file it there.");
228
+ }
229
+ return body.ok === true ? 0 : 1;
230
+ }
183
231
  export async function moveNote(command, door) {
184
232
  const answer = await ask(door, {
185
233
  path: "/api/notes/move",
@@ -188,7 +236,10 @@ export async function moveNote(command, door) {
188
236
  timeoutMs: READ_DEADLINE_MS,
189
237
  // `--clear-shelf` sends the empty string, which is what the browser's own
190
238
  // move box sends when a person empties it.
191
- body: { note_id: command.noteId, category: command.clearShelf ? "" : command.to },
239
+ body: { note_id: command.noteId,
240
+ ...(command.folder ? { folder_path: command.folder } : {}),
241
+ ...(command.to || command.clearShelf ? { category: command.clearShelf ? "" : command.to } : {}),
242
+ },
192
243
  });
193
244
  if (!answer.ok)
194
245
  return fail(door, answer.reason, answer.detail);
@@ -205,4 +256,23 @@ export async function moveNote(command, door) {
205
256
  for (const line of body.lines ?? [])
206
257
  writeLine(door.io.stdout, line);
207
258
  return body.ok === true ? 0 : 1;
259
+ }
260
+ /** Bounded polling of the same note read used by the browser and MCP. */
261
+ async function waitForTranscription(door, noteId) {
262
+ const deadline = Date.now() + 330_000;
263
+ for (let attempt = 0; attempt < 110 && Date.now() < deadline; attempt++) {
264
+ const answer = await ask(door, { path: `/api/notes/library/${encodeURIComponent(noteId)}`,
265
+ method: "GET", label: "notes transcription", timeoutMs: Math.min(10_000, deadline - Date.now()) });
266
+ if (!answer.ok)
267
+ return { status: "failed", reason: answer.reason };
268
+ const status = answer.body.audio?.transcription_status;
269
+ if (status === "done")
270
+ return { status: "done" };
271
+ if (status?.startsWith("failed:"))
272
+ return { status: "failed", reason: status.slice(7) };
273
+ if (status !== "pending")
274
+ return { status: "failed", reason: "transcription_status_unavailable" };
275
+ await new Promise((resolve) => setTimeout(resolve, Math.min(3000, Math.max(0, deadline - Date.now()))));
276
+ }
277
+ return { status: "failed", reason: "transcription_wait_timeout" };
208
278
  }
@@ -27,7 +27,7 @@
27
27
  * table of contents). Every responsibility lives in a `notes-*.ts` sibling:
28
28
  *
29
29
  * - `notes-reads.ts` — the library, shelves list, one note, your own shelf.
30
- * - `notes-writes.ts` — upload, paste, share/unshare, move.
30
+ * - `notes-writes.ts` — upload, paste, share/unshare, move, place.
31
31
  * - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
32
32
  * emit / say-* rendering both halves above call.
33
33
  *
@@ -35,9 +35,10 @@
35
35
  */
36
36
  import { loadPairedSession } from "../tower-client.js";
37
37
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
38
- import { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
38
+ import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
39
+ import { runFolderCommand } from "./notes-folders.js";
39
40
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
40
- export { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
41
+ export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
41
42
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
42
43
  export async function runNotes(command, io) {
43
44
  const session = await loadPairedSession("notes", command.homeDir);
@@ -48,6 +49,11 @@ export async function runNotes(command, io) {
48
49
  json: command.json,
49
50
  };
50
51
  switch (command.action) {
52
+ case "folders":
53
+ case "mkdir":
54
+ case "rmdir":
55
+ case "rename":
56
+ return runFolderCommand(command, door);
51
57
  case "list":
52
58
  return listNotes(command, door);
53
59
  case "shelves":
@@ -65,5 +71,7 @@ export async function runNotes(command, io) {
65
71
  return shareNote(command, door);
66
72
  case "move":
67
73
  return moveNote(command, door);
74
+ case "place":
75
+ return placeNote(command, door);
68
76
  }
69
77
  }
@@ -101,7 +101,11 @@ options = {}) {
101
101
  // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
102
102
  // fleet", not "one machine in it", and the name is otherwise buried in
103
103
  // `detail` below.
104
- const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
104
+ // BLI-4057 generalised it from `stale` alone: `memory-hooks` names the
105
+ // machine whose hooks stopped and reads `failing`, because nothing about
106
+ // it is late. A row that took the trouble to name a device is naming it
107
+ // for the reader, whatever word it printed beside it.
108
+ const staleName = row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
105
109
  lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
106
110
  if (row.verdict !== "healthy") {
107
111
  // The absence of an age is explained BEFORE the detail, because it is
@@ -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.98");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.100");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,23 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function runUsage(command, io) {
4
+ const door = await openAgentDoor("usage", command, io);
5
+ const query = new URLSearchParams({ since: command.since });
6
+ if (command.until)
7
+ query.set("until", command.until);
8
+ if (command.includeAutomated)
9
+ query.set("include_automated", "1");
10
+ const answer = await askAgentDoor(door, { path: `/api/usage/people?${query}`, method: "GET", label: "usage people", timeoutMs: 30_000 });
11
+ if (!answer.ok)
12
+ return failAgentDoor(door, "[usage]", answer.reason, answer.detail);
13
+ const body = answer.body;
14
+ if (door.json)
15
+ return emitAgentDoor(door, body);
16
+ writeLine(io.stdout, "PERSON TOKENS OUTPUT LIST EQUIVALENT COVERAGE");
17
+ for (const row of body.people ?? [])
18
+ writeLine(io.stdout, `${(row.display_name ?? row.email ?? "Unknown").slice(0, 20).padEnd(20)} ${String(row.tokens_total).padStart(11)} ${String(row.output_tokens).padStart(11)} ${`$${row.api_list_price_equivalent_usd.toFixed(2)}`.padStart(15)} ${row.sessions_extracted}/${row.sessions_observed}`);
19
+ writeLine(io.stdout, "");
20
+ writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
21
+ writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
22
+ return 0;
23
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A run that dies must not be able to say it worked (BLI-4110).
3
+ *
4
+ * On 2026-09-09 `cockpit do-everything` on the founder's Windows box reached
5
+ * "windows task repair converged", printed `Error: read ENOTCONN` from
6
+ * `child_process.spawn`, and **exited 0**. That is the worse half of that
7
+ * incident. A repair that fails loudly gets looked at; a repair that dies
8
+ * while claiming success stops anybody looking at all, and that machine had
9
+ * not delivered a session in 29 days.
10
+ *
11
+ * **What was measured** (Node 22.20, win32), because the fix depends entirely
12
+ * on which shapes can produce a zero:
13
+ *
14
+ * | shape | exit code |
15
+ * | -- | -- |
16
+ * | `throw` from a `setImmediate` after `process.exitCode = 0` | 1 |
17
+ * | an unhandled promise rejection | 1 |
18
+ * | an `error` event on an inherited stdio stream | 1 |
19
+ * | a `throw` inside a `process.on("exit")` handler | **0** |
20
+ *
21
+ * Node's own defaults are honest about every asynchronous crash. The zero can
22
+ * only come from the window where OUR code is the last thing holding the exit
23
+ * code — and `cli.ts` held it in the most optimistic way available:
24
+ *
25
+ * const exitCode = await runCockpitCli(argv);
26
+ * process.exitCode = exitCode;
27
+ *
28
+ * `process.exitCode` starts at 0. So *anything* that prevents that assignment
29
+ * from being reached, or that runs after it, reports success by default.
30
+ * Success was the resting state and had to be disproved.
31
+ *
32
+ * **The fix inverts that: success must be earned.** The entry point sets a
33
+ * non-zero code BEFORE doing any work, and only a command that actually
34
+ * returned is allowed to lower it. Nothing here has to enumerate the ways a
35
+ * run can die, which matters because the specific `read ENOTCONN` path has not
36
+ * been reproduced off that machine — see `docs/runbooks/` and the ticket. A
37
+ * guard that depends on correctly predicting the crash is a guard that works
38
+ * on the crashes you already knew about.
39
+ *
40
+ * The guard only ever RAISES a zero. It can never turn a real failure into a
41
+ * success, and it never overwrites a code a command chose.
42
+ */
43
+ /**
44
+ * Distinct from 1 on purpose. `1` is "this ran and the answer is no"; 70 is
45
+ * "this never finished, so there is no answer". A scheduled task's log can
46
+ * then tell a failed repair from an abandoned one without parsing prose.
47
+ */
48
+ export const EXIT_DIED_MID_RUN = 70;
49
+ /** Stages nest — the subcommand, then the invariant running inside it. */
50
+ const openStages = [];
51
+ let commandReturned = false;
52
+ let installed = false;
53
+ export function beginStage(stage) {
54
+ openStages.push(stage);
55
+ }
56
+ /**
57
+ * Closes the innermost stage. Takes the name so an unbalanced pair is visible:
58
+ * closing a stage that is not the open one would make a later crash blame the
59
+ * wrong stage, which is worse than naming none.
60
+ */
61
+ export function endStage(stage) {
62
+ const top = openStages[openStages.length - 1];
63
+ if (top !== stage) {
64
+ console.error("[cockpit-cli] stage mismatch", JSON.stringify({ reason: "stage_mismatch", closing: stage, open: top ?? null }));
65
+ return;
66
+ }
67
+ openStages.pop();
68
+ }
69
+ /** The innermost stage still running, or null. Naming only — never a verdict. */
70
+ export function openStage() {
71
+ return openStages[openStages.length - 1] ?? null;
72
+ }
73
+ /**
74
+ * Called by the entry point when the command function actually returned a
75
+ * code. This is the ONE fact the guard trusts: not "did a stage close", not
76
+ * "were there failures", but "did control come back".
77
+ */
78
+ export function markCommandReturned() {
79
+ commandReturned = true;
80
+ }
81
+ /** Test seam — the guard is process-global, so a suite must reset it. */
82
+ export function resetCrashGuardForTest() {
83
+ openStages.length = 0;
84
+ commandReturned = false;
85
+ installed = false;
86
+ }
87
+ /**
88
+ * Error facts that are safe to log: names and codes, never a message. An error
89
+ * message routinely carries a path under somebody's home directory, and this
90
+ * line is read off a fleet-visible log.
91
+ */
92
+ function describeCrash(error) {
93
+ if (!(error instanceof Error)) {
94
+ return { error_name: typeof error, error_code: null, error_syscall: null };
95
+ }
96
+ const record = error;
97
+ return {
98
+ error_name: error.name,
99
+ error_code: record.code ?? null,
100
+ error_syscall: record.syscall ?? null,
101
+ };
102
+ }
103
+ /**
104
+ * Installs the guard. Idempotent — the entry point is also imported by tests.
105
+ *
106
+ * `uncaughtExceptionMonitor`, not `uncaughtException`, and the difference is
107
+ * the whole safety argument: the monitor OBSERVES and lets Node's default
108
+ * handling proceed, so the stack trace still prints and the process still
109
+ * dies. Registering `uncaughtException` would SUPPRESS the default, leaving
110
+ * this guard to re-implement crashing correctly — which is precisely how a
111
+ * safety net becomes the thing that swallows the error. Since Node 15 an
112
+ * unhandled rejection is raised as an uncaught exception by default, so the
113
+ * monitor names those too without a second listener.
114
+ */
115
+ export function installCrashGuard(options = {}) {
116
+ const target = options.process ?? process;
117
+ if (installed)
118
+ return;
119
+ installed = true;
120
+ target.on("uncaughtExceptionMonitor", (error) => {
121
+ console.error("[cockpit-cli] crashed", JSON.stringify({
122
+ reason: "uncaught_exception",
123
+ stage: openStage(),
124
+ ...describeCrash(error),
125
+ }));
126
+ });
127
+ target.on("exit", () => {
128
+ if (commandReturned)
129
+ return;
130
+ // `EXIT_DIED_MID_RUN` has to be in this set, and leaving it out made this
131
+ // whole branch dead code in the shipped binary: `cli.ts` presets 70 BEFORE
132
+ // any work, so by the time `exit` fires the code is 70 (quiet death) or 1
133
+ // (Node overwrote it on an uncaught exception) and never 0. The exit CODE
134
+ // was right; the line that names the stage never printed once. Caught in
135
+ // review by transcribing this file plus `cli.ts` and running every death
136
+ // shape — not by the unit test, which passed only because it hand-set 0,
137
+ // a state the entry point cannot reach.
138
+ if (target.exitCode !== 0 &&
139
+ target.exitCode !== undefined &&
140
+ target.exitCode !== EXIT_DIED_MID_RUN) {
141
+ return;
142
+ }
143
+ console.error("[cockpit-cli] died mid-run", JSON.stringify({
144
+ reason: "died_mid_run",
145
+ stage: openStage(),
146
+ corrected_exit_code: EXIT_DIED_MID_RUN,
147
+ }));
148
+ target.exitCode = EXIT_DIED_MID_RUN;
149
+ });
150
+ }
151
+ /**
152
+ * Runs `body` as a named stage so a crash inside it can say where it was.
153
+ *
154
+ * The `finally` closes the stage on both paths on purpose: a stage that THREW
155
+ * is finished, and its exception is already travelling to somebody who will
156
+ * set a real exit code. Naming is all this does — the exit-code decision rests
157
+ * on `markCommandReturned`, never on whether a stage happens to be open.
158
+ */
159
+ export async function withStage(stage, body) {
160
+ beginStage(stage);
161
+ try {
162
+ return await body();
163
+ }
164
+ finally {
165
+ endStage(stage);
166
+ }
167
+ }