@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.
- package/dist/cli.js +13 -0
- package/dist/commands/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-issues.js +8 -55
- package/dist/commands/backfill-report.js +22 -6
- package/dist/commands/backfill-scan.js +2 -1
- package/dist/commands/backfill-skip-policy.js +134 -0
- package/dist/commands/careers.js +16 -0
- package/dist/commands/doctor-pipeline-verdicts.js +238 -0
- package/dist/commands/doctor-pipeline.js +37 -107
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/local-args-tower-admin.js +17 -6
- package/dist/commands/local-args-tower-careers.js +20 -0
- package/dist/commands/local-args-tower-pages.js +23 -3
- package/dist/commands/local-args-tower-usage.js +8 -0
- package/dist/commands/local-args-tower.js +3 -1
- package/dist/commands/local-args.js +5 -1
- package/dist/commands/local-help-commands-tower.js +32 -5
- package/dist/commands/local-help-commands.js +11 -2
- package/dist/commands/local-help.js +8 -3
- package/dist/commands/local.js +13 -0
- package/dist/commands/memory-hook-counts.js +29 -8
- package/dist/commands/notes-file.js +8 -1
- package/dist/commands/notes-folders.js +35 -0
- package/dist/commands/notes-writes.js +74 -4
- package/dist/commands/notes.js +11 -3
- package/dist/commands/ops-render.js +5 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/usage.js +23 -0
- package/dist/crash-guard.js +167 -0
- package/dist/cursors/backfill-completion-marker.js +135 -0
- package/dist/cursors/backfill-cursor.js +18 -99
- package/dist/process-runner.js +39 -1
- package/dist/sync-lock.js +10 -1
- package/package.json +5 -5
package/dist/commands/local.js
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
* the one whose budget QA tick 18 caught it
|
|
18
|
-
* every turn — so it is the only one whose
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
}
|