@bli-cockpit/cli 0.2.99 → 0.2.101
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/agent-rules.js +2 -1
- package/dist/backfill-lock.js +1 -1
- 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-access.js +34 -10
- package/dist/commands/doctor-lock-wait.js +46 -0
- package/dist/commands/doctor-pipeline-verdicts.js +238 -0
- package/dist/commands/doctor-pipeline.js +49 -111
- package/dist/commands/doctor-registration.js +23 -2
- package/dist/commands/doctor-report.js +48 -9
- package/dist/commands/doctor-update.js +16 -5
- package/dist/commands/doctor.js +98 -58
- package/dist/commands/local-args-collector-setup.js +6 -0
- package/dist/commands/local-args-tower-careers.js +20 -0
- package/dist/commands/local-args-tower-pages.js +17 -2
- package/dist/commands/local-args-tower-usage.js +2 -2
- package/dist/commands/local-args-tower.js +2 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +4 -2
- package/dist/commands/local-help-commands.js +28 -12
- package/dist/commands/local-help.js +4 -2
- package/dist/commands/local.js +4 -0
- package/dist/commands/notes-file.js +8 -1
- package/dist/commands/notes-folders.js +35 -0
- package/dist/commands/notes-writes.js +37 -4
- package/dist/commands/notes.js +6 -0
- package/dist/commands/public-root.js +4 -4
- package/dist/commands/usage-format.js +18 -0
- package/dist/commands/usage.js +13 -3
- package/dist/cursors/backfill-completion-marker.js +135 -0
- package/dist/cursors/backfill-cursor.js +18 -99
- package/dist/scheduled-self-update.js +1 -1
- package/dist/sync-lock.js +15 -1
- package/package.json +2 -2
|
@@ -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
|
|
@@ -225,7 +236,10 @@ export async function moveNote(command, door) {
|
|
|
225
236
|
timeoutMs: READ_DEADLINE_MS,
|
|
226
237
|
// `--clear-shelf` sends the empty string, which is what the browser's own
|
|
227
238
|
// move box sends when a person empties it.
|
|
228
|
-
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
|
+
},
|
|
229
243
|
});
|
|
230
244
|
if (!answer.ok)
|
|
231
245
|
return fail(door, answer.reason, answer.detail);
|
|
@@ -242,4 +256,23 @@ export async function moveNote(command, door) {
|
|
|
242
256
|
for (const line of body.lines ?? [])
|
|
243
257
|
writeLine(door.io.stdout, line);
|
|
244
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" };
|
|
245
278
|
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { loadPairedSession } from "../tower-client.js";
|
|
37
37
|
import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
38
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
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";
|
|
@@ -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":
|
|
@@ -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.101");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -40,12 +40,12 @@ function cockpitHelp() {
|
|
|
40
40
|
localCommandHelp(),
|
|
41
41
|
"",
|
|
42
42
|
"Install: `npm install -g @bli-cockpit/cli@latest`.",
|
|
43
|
-
"Fix everything: run `cockpit do-everything`
|
|
43
|
+
"Fix everything: run `cockpit doctor`. `do-everything` and `fix` are exact aliases; --check diagnoses without repairs.",
|
|
44
44
|
"Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
|
|
45
45
|
"Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
|
|
46
46
|
"Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
|
|
47
|
-
"Already onboarded: run `cockpit
|
|
48
|
-
"Agent setup: `cockpit
|
|
47
|
+
"Already onboarded: run `cockpit doctor` from anywhere to repair and verify this machine.",
|
|
48
|
+
"Agent setup: `cockpit doctor` installs and verifies AGENTS.md/CLAUDE.md rules.",
|
|
49
49
|
"Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
|
|
50
50
|
"Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
|
|
51
51
|
"Maintainer release path: merge to main, publish to `next` with `cockpit release`, canary Windows plus Apple Silicon, then deliberately promote that exact version to `latest`.",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Compact counts for human-facing usage tables. JSON keeps the original numbers. */
|
|
2
|
+
export function formatCount(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return "Unavailable";
|
|
5
|
+
// Promote rounded values at unit boundaries instead of printing 1000K or 1000M.
|
|
6
|
+
if (value >= 999_950_000)
|
|
7
|
+
return `${Number((value / 1_000_000_000).toFixed(1))}B`;
|
|
8
|
+
if (value >= 999_500)
|
|
9
|
+
return `${Number((value / 1_000_000).toFixed(1))}M`;
|
|
10
|
+
if (value >= 1_000)
|
|
11
|
+
return `${Math.round(value / 1_000)}K`;
|
|
12
|
+
return String(value);
|
|
13
|
+
}
|
|
14
|
+
export function formatUsageDollars(value) {
|
|
15
|
+
return new Intl.NumberFormat("en-US", {
|
|
16
|
+
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
|
17
|
+
}).format(value);
|
|
18
|
+
}
|
package/dist/commands/usage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatCount, formatUsageDollars } from "./usage-format.js";
|
|
1
2
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
3
|
import { writeLine } from "./cli-io.js";
|
|
3
4
|
export async function runUsage(command, io) {
|
|
@@ -13,9 +14,18 @@ export async function runUsage(command, io) {
|
|
|
13
14
|
const body = answer.body;
|
|
14
15
|
if (door.json)
|
|
15
16
|
return emitAgentDoor(door, body);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const widths = [20, 8, 15, 12, 10, 10, 12, 14];
|
|
18
|
+
const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
|
|
19
|
+
printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
|
|
20
|
+
for (const row of body.people ?? []) {
|
|
21
|
+
printRow([
|
|
22
|
+
(row.display_name ?? row.email ?? "Unknown").slice(0, 20),
|
|
23
|
+
formatCount(row.tokens_total),
|
|
24
|
+
formatUsageDollars(row.api_list_price_equivalent_usd),
|
|
25
|
+
`${row.sessions_extracted}/${row.sessions_observed}`,
|
|
26
|
+
...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
19
29
|
writeLine(io.stdout, "");
|
|
20
30
|
writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
|
|
21
31
|
writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The all-history completion marker: the one document that says an `--all`
|
|
3
|
+
* backfill finished, and the only proof `cockpit doctor` accepts for its
|
|
4
|
+
* `backfill-complete` row.
|
|
5
|
+
*
|
|
6
|
+
* It lives beside the cursor and not inside it because it answers a different
|
|
7
|
+
* question. The cursor is where the sweep GOT TO and is rewritten constantly;
|
|
8
|
+
* the marker is a claim about a finished scope, with its own schema version,
|
|
9
|
+
* its own 24-hour revalidation, and its own rule about which leftovers are
|
|
10
|
+
* allowed to ride along on a run that still counts as complete (a file over
|
|
11
|
+
* the upload cap, BLI-2727; helper transcripts over the per-session sidecar
|
|
12
|
+
* cap, BLI-4303). Both of those are recorded here, never silently dropped.
|
|
13
|
+
*
|
|
14
|
+
* Every name is re-exported from `./backfill-cursor.js`, the address its
|
|
15
|
+
* callers already know.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
20
|
+
import { backfillCollectionScopeId, parseBackfillCursor, writePrivateBackfillJson, } from "./backfill-cursor.js";
|
|
21
|
+
export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
|
|
22
|
+
export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
|
|
23
|
+
export const BACKFILL_COMPLETION_RECHECK_MS = 24 * 60 * 60 * 1_000;
|
|
24
|
+
export async function writeBackfillCompletionMarker(paths, marker) {
|
|
25
|
+
await writePrivateBackfillJson(backfillCompletionMarkerPath(paths), marker);
|
|
26
|
+
}
|
|
27
|
+
export async function readBackfillCompletionMarker(paths) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
30
|
+
return parseBackfillCompletionMarker(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// No marker means backfill has not finished, which is the ordinary state.
|
|
34
|
+
// A marker that cannot be read means a finished backfill will be re-run,
|
|
35
|
+
// and the machine should say so rather than quietly redo a day of work.
|
|
36
|
+
if (!isMissingFileFailure(error)) {
|
|
37
|
+
console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
|
|
38
|
+
reason: "backfill_marker_unreadable",
|
|
39
|
+
marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
|
|
40
|
+
...describeError(error),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function backfillCompletionCovers(marker, collectionRoots, requiredSources, now = new Date()) {
|
|
47
|
+
if (!marker)
|
|
48
|
+
return false;
|
|
49
|
+
if (marker.coverage_version !== BACKFILL_COVERAGE_VERSION)
|
|
50
|
+
return false;
|
|
51
|
+
if (marker.collection_scope_id !== backfillCollectionScopeId(collectionRoots)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const revalidateAfter = Date.parse(marker.revalidate_after);
|
|
55
|
+
if (!Number.isFinite(revalidateAfter) || now.getTime() >= revalidateAfter) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const completedSources = new Set(marker.sources);
|
|
59
|
+
return requiredSources.every((source) => completedSources.has(source));
|
|
60
|
+
}
|
|
61
|
+
export function backfillCompletionMarkerPath(paths) {
|
|
62
|
+
return path.join(paths.cursors_dir, BACKFILL_COMPLETION_MARKER_FILENAME);
|
|
63
|
+
}
|
|
64
|
+
function parseBackfillCompletionMarker(value) {
|
|
65
|
+
if (!value || typeof value !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const record = value;
|
|
68
|
+
if (record["schema_version"] !== "cockpit-backfill-complete.v2" ||
|
|
69
|
+
record["coverage_version"] !== BACKFILL_COVERAGE_VERSION) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const collectionScopeId = optionalString(record["collection_scope_id"]);
|
|
73
|
+
const completedAt = optionalString(record["completed_at"]);
|
|
74
|
+
const revalidateAfter = optionalString(record["revalidate_after"]);
|
|
75
|
+
const rawSources = record["sources"];
|
|
76
|
+
if (!collectionScopeId ||
|
|
77
|
+
!completedAt ||
|
|
78
|
+
!revalidateAfter ||
|
|
79
|
+
!Number.isFinite(Date.parse(revalidateAfter)) ||
|
|
80
|
+
!Array.isArray(rawSources)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const sources = [
|
|
84
|
+
...new Set(rawSources.filter((source) => source === "codex" || source === "claude_code")),
|
|
85
|
+
];
|
|
86
|
+
if (sources.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
schema_version: "cockpit-backfill-complete.v2",
|
|
90
|
+
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
91
|
+
collection_scope_id: collectionScopeId,
|
|
92
|
+
sources,
|
|
93
|
+
completed_at: completedAt,
|
|
94
|
+
revalidate_after: revalidateAfter,
|
|
95
|
+
cursor: parseBackfillCursor(record["cursor"]),
|
|
96
|
+
...(parseOversizedSkips(record["oversized_skips"])
|
|
97
|
+
? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
|
|
98
|
+
: {}),
|
|
99
|
+
...(parseSidecarCapSkips(record["sidecar_cap_skips"])
|
|
100
|
+
? { sidecar_cap_skips: parseSidecarCapSkips(record["sidecar_cap_skips"]) }
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseSidecarCapSkips(value) {
|
|
105
|
+
if (!value || typeof value !== "object")
|
|
106
|
+
return null;
|
|
107
|
+
const record = value;
|
|
108
|
+
if (record["reason"] !== "claude_sidecar_limit_applied")
|
|
109
|
+
return null;
|
|
110
|
+
const count = optionalNumber(record["count"]);
|
|
111
|
+
if (count === null || count <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
return { reason: "claude_sidecar_limit_applied", count };
|
|
114
|
+
}
|
|
115
|
+
function parseOversizedSkips(value) {
|
|
116
|
+
if (!value || typeof value !== "object")
|
|
117
|
+
return null;
|
|
118
|
+
const record = value;
|
|
119
|
+
if (record["reason"] !== "file_too_large")
|
|
120
|
+
return null;
|
|
121
|
+
const count = optionalNumber(record["count"]);
|
|
122
|
+
if (count === null || count <= 0)
|
|
123
|
+
return null;
|
|
124
|
+
const rawByteSizes = record["byte_sizes"];
|
|
125
|
+
const byteSizes = Array.isArray(rawByteSizes)
|
|
126
|
+
? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
|
|
127
|
+
: [];
|
|
128
|
+
return { reason: "file_too_large", count, byte_sizes: byteSizes };
|
|
129
|
+
}
|
|
130
|
+
function optionalString(value) {
|
|
131
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
132
|
+
}
|
|
133
|
+
function optionalNumber(value) {
|
|
134
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
135
|
+
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the historical sweep got to, per source: the backfill cursor.
|
|
3
|
+
*
|
|
4
|
+
* Two things live in this folder and they are deliberately separate. This one
|
|
5
|
+
* is a moving position — a newest/oldest watermark, the identities sitting
|
|
6
|
+
* exactly on each boundary, and the census of what was seen — rewritten by
|
|
7
|
+
* every run. Its sibling `backfill-completion-marker.ts` is the claim that a
|
|
8
|
+
* whole scope is FINISHED, with its own schema and its own rules; every name
|
|
9
|
+
* it owns is re-exported at the bottom of this file, so callers keep one
|
|
10
|
+
* address.
|
|
11
|
+
*/
|
|
1
12
|
import crypto from "node:crypto";
|
|
2
13
|
import fs from "node:fs/promises";
|
|
3
14
|
import path from "node:path";
|
|
4
15
|
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
5
16
|
export const BACKFILL_CURSOR_FILENAME = "backfill.json";
|
|
6
|
-
export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
|
|
7
|
-
export const BACKFILL_COVERAGE_VERSION = "redacted-session-backfill.v3";
|
|
8
|
-
export const BACKFILL_COMPLETION_RECHECK_MS = 24 * 60 * 60 * 1_000;
|
|
9
17
|
export function emptyBackfillCursorState() {
|
|
10
18
|
return {
|
|
11
19
|
schema_version: "cockpit-backfill-cursor.v1",
|
|
@@ -37,29 +45,7 @@ export async function readBackfillCursor(paths) {
|
|
|
37
45
|
}
|
|
38
46
|
export async function writeBackfillCursor(paths, state) {
|
|
39
47
|
const filePath = backfillCursorPath(paths);
|
|
40
|
-
await
|
|
41
|
-
}
|
|
42
|
-
export async function writeBackfillCompletionMarker(paths, marker) {
|
|
43
|
-
await writePrivateJson(backfillCompletionMarkerPath(paths), marker);
|
|
44
|
-
}
|
|
45
|
-
export async function readBackfillCompletionMarker(paths) {
|
|
46
|
-
try {
|
|
47
|
-
const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
48
|
-
return parseBackfillCompletionMarker(raw);
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
// No marker means backfill has not finished, which is the ordinary state.
|
|
52
|
-
// A marker that cannot be read means a finished backfill will be re-run,
|
|
53
|
-
// and the machine should say so rather than quietly redo a day of work.
|
|
54
|
-
if (!isMissingFileFailure(error)) {
|
|
55
|
-
console.error("[backfill-cursor] completion marker unreadable, treating backfill as unfinished", JSON.stringify({
|
|
56
|
-
reason: "backfill_marker_unreadable",
|
|
57
|
-
marker_file: BACKFILL_COMPLETION_MARKER_FILENAME,
|
|
58
|
-
...describeError(error),
|
|
59
|
-
}));
|
|
60
|
-
}
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
48
|
+
await writePrivateBackfillJson(filePath, state);
|
|
63
49
|
}
|
|
64
50
|
/**
|
|
65
51
|
* The cursor is only reusable inside the exact approved-root scope that
|
|
@@ -83,21 +69,6 @@ export function prepareBackfillCursorForScope(cursor, collectionRoots, sources)
|
|
|
83
69
|
reset,
|
|
84
70
|
};
|
|
85
71
|
}
|
|
86
|
-
export function backfillCompletionCovers(marker, collectionRoots, requiredSources, now = new Date()) {
|
|
87
|
-
if (!marker)
|
|
88
|
-
return false;
|
|
89
|
-
if (marker.coverage_version !== BACKFILL_COVERAGE_VERSION)
|
|
90
|
-
return false;
|
|
91
|
-
if (marker.collection_scope_id !== backfillCollectionScopeId(collectionRoots)) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
const revalidateAfter = Date.parse(marker.revalidate_after);
|
|
95
|
-
if (!Number.isFinite(revalidateAfter) || now.getTime() >= revalidateAfter) {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
const completedSources = new Set(marker.sources);
|
|
99
|
-
return requiredSources.every((source) => completedSources.has(source));
|
|
100
|
-
}
|
|
101
72
|
export function backfillCollectionScopeId(collectionRoots) {
|
|
102
73
|
const normalized = [...new Set(collectionRoots.map(normalizeScopeRoot))].sort();
|
|
103
74
|
return `scope-${crypto
|
|
@@ -178,9 +149,6 @@ export function recordBackfillScanCoverage(cursor, sources, coveredThrough, now)
|
|
|
178
149
|
export function backfillCursorPath(paths) {
|
|
179
150
|
return path.join(paths.cursors_dir, BACKFILL_CURSOR_FILENAME);
|
|
180
151
|
}
|
|
181
|
-
export function backfillCompletionMarkerPath(paths) {
|
|
182
|
-
return path.join(paths.cursors_dir, BACKFILL_COMPLETION_MARKER_FILENAME);
|
|
183
|
-
}
|
|
184
152
|
function emptySourceCursor(collectionScopeId = null) {
|
|
185
153
|
return {
|
|
186
154
|
collection_scope_id: collectionScopeId,
|
|
@@ -194,7 +162,7 @@ function emptySourceCursor(collectionScopeId = null) {
|
|
|
194
162
|
reason_counts: {},
|
|
195
163
|
};
|
|
196
164
|
}
|
|
197
|
-
function parseBackfillCursor(value) {
|
|
165
|
+
export function parseBackfillCursor(value) {
|
|
198
166
|
if (!value || typeof value !== "object")
|
|
199
167
|
return emptyBackfillCursorState();
|
|
200
168
|
const record = value;
|
|
@@ -228,58 +196,6 @@ function parseSourceCursor(value) {
|
|
|
228
196
|
reason_counts: parseNumberRecord(record["reason_counts"]),
|
|
229
197
|
};
|
|
230
198
|
}
|
|
231
|
-
function parseBackfillCompletionMarker(value) {
|
|
232
|
-
if (!value || typeof value !== "object")
|
|
233
|
-
return null;
|
|
234
|
-
const record = value;
|
|
235
|
-
if (record["schema_version"] !== "cockpit-backfill-complete.v2" ||
|
|
236
|
-
record["coverage_version"] !== BACKFILL_COVERAGE_VERSION) {
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
const collectionScopeId = optionalString(record["collection_scope_id"]);
|
|
240
|
-
const completedAt = optionalString(record["completed_at"]);
|
|
241
|
-
const revalidateAfter = optionalString(record["revalidate_after"]);
|
|
242
|
-
const rawSources = record["sources"];
|
|
243
|
-
if (!collectionScopeId ||
|
|
244
|
-
!completedAt ||
|
|
245
|
-
!revalidateAfter ||
|
|
246
|
-
!Number.isFinite(Date.parse(revalidateAfter)) ||
|
|
247
|
-
!Array.isArray(rawSources)) {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
const sources = [
|
|
251
|
-
...new Set(rawSources.filter((source) => source === "codex" || source === "claude_code")),
|
|
252
|
-
];
|
|
253
|
-
if (sources.length === 0)
|
|
254
|
-
return null;
|
|
255
|
-
return {
|
|
256
|
-
schema_version: "cockpit-backfill-complete.v2",
|
|
257
|
-
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
258
|
-
collection_scope_id: collectionScopeId,
|
|
259
|
-
sources,
|
|
260
|
-
completed_at: completedAt,
|
|
261
|
-
revalidate_after: revalidateAfter,
|
|
262
|
-
cursor: parseBackfillCursor(record["cursor"]),
|
|
263
|
-
...(parseOversizedSkips(record["oversized_skips"])
|
|
264
|
-
? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
|
|
265
|
-
: {}),
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
function parseOversizedSkips(value) {
|
|
269
|
-
if (!value || typeof value !== "object")
|
|
270
|
-
return null;
|
|
271
|
-
const record = value;
|
|
272
|
-
if (record["reason"] !== "file_too_large")
|
|
273
|
-
return null;
|
|
274
|
-
const count = optionalNumber(record["count"]);
|
|
275
|
-
if (count === null || count <= 0)
|
|
276
|
-
return null;
|
|
277
|
-
const rawByteSizes = record["byte_sizes"];
|
|
278
|
-
const byteSizes = Array.isArray(rawByteSizes)
|
|
279
|
-
? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
|
|
280
|
-
: [];
|
|
281
|
-
return { reason: "file_too_large", count, byte_sizes: byteSizes };
|
|
282
|
-
}
|
|
283
199
|
function normalizeScopeRoot(value) {
|
|
284
200
|
const windowsStyle = path.win32.isAbsolute(value) && !path.posix.isAbsolute(value);
|
|
285
201
|
if (windowsStyle)
|
|
@@ -305,7 +221,7 @@ function parseStringArray(value) {
|
|
|
305
221
|
...new Set(value.filter((entry) => typeof entry === "string" && entry.trim().length > 0)),
|
|
306
222
|
].sort();
|
|
307
223
|
}
|
|
308
|
-
async function
|
|
224
|
+
export async function writePrivateBackfillJson(filePath, value) {
|
|
309
225
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
310
226
|
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
311
227
|
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
@@ -334,4 +250,7 @@ function optionalString(value) {
|
|
|
334
250
|
}
|
|
335
251
|
function optionalNumber(value) {
|
|
336
252
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
337
|
-
}
|
|
253
|
+
}
|
|
254
|
+
// Re-exported so every caller keeps importing the completion marker from
|
|
255
|
+
// `./backfill-cursor.js`, the address it has always had.
|
|
256
|
+
export { BACKFILL_COMPLETION_MARKER_FILENAME, BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, backfillCompletionCovers, backfillCompletionMarkerPath, readBackfillCompletionMarker, writeBackfillCompletionMarker, } from "./backfill-completion-marker.js";
|
|
@@ -146,7 +146,7 @@ function parseSemverTriple(value) {
|
|
|
146
146
|
return null;
|
|
147
147
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
148
148
|
}
|
|
149
|
-
function isSemverBelow(left, right) {
|
|
149
|
+
export function isSemverBelow(left, right) {
|
|
150
150
|
const parsedLeft = parseSemverTriple(left);
|
|
151
151
|
const parsedRight = parseSemverTriple(right);
|
|
152
152
|
if (!parsedLeft || !parsedRight)
|
package/dist/sync-lock.js
CHANGED
|
@@ -15,7 +15,16 @@ import { describeError } from "./health-detail.js";
|
|
|
15
15
|
*/
|
|
16
16
|
const LOCK_FILENAME = "sync.lock";
|
|
17
17
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* How long a lock may go without a heartbeat before the next sync takes it
|
|
20
|
+
* over. Exported because it is also what "the owner is still alive" MEANS on
|
|
21
|
+
* this machine: a receipt that says `sync_already_running` and carries a
|
|
22
|
+
* heartbeat inside this window was refused by a process that is still running
|
|
23
|
+
* (BLI-4303). Doctor reads it that way instead of calling a contended lock a
|
|
24
|
+
* failure.
|
|
25
|
+
*/
|
|
26
|
+
export const SYNC_LOCK_STALE_TAKEOVER_MS = 2 * 60_000;
|
|
27
|
+
const STALE_TAKEOVER_MS = SYNC_LOCK_STALE_TAKEOVER_MS;
|
|
19
28
|
export async function acquireSyncLock(paths, now = new Date()) {
|
|
20
29
|
const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
|
|
21
30
|
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
@@ -133,4 +142,9 @@ async function readLock(lockPath) {
|
|
|
133
142
|
// ACQUIRE above is the branch that owns the reporting.
|
|
134
143
|
return null;
|
|
135
144
|
}
|
|
145
|
+
}
|
|
146
|
+
/** Read owner metadata without acquiring or disturbing the collection lock. */
|
|
147
|
+
export async function inspectSyncLock(paths) {
|
|
148
|
+
const record = await readLock(path.join(paths.state_dir, LOCK_FILENAME));
|
|
149
|
+
return record ? { pid: record.pid, heartbeat_at: record.heartbeat_at, held: Date.now() - record.heartbeat_ms <= STALE_TAKEOVER_MS } : null;
|
|
136
150
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.101",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.26",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.32",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.43"
|
|
33
33
|
}
|
|
34
34
|
}
|