@bli-cockpit/cli 0.2.103 → 0.2.106
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/commands/backfill-upload.js +31 -1
- package/dist/commands/doctor-pipeline.js +24 -5
- package/dist/commands/local-args-tower-usage.js +4 -2
- package/dist/commands/local-help-commands-tower.js +2 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/usage-format.js +2 -0
- package/dist/commands/usage.js +36 -1
- package/package.json +2 -2
|
@@ -18,7 +18,14 @@ const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
18
18
|
*/
|
|
19
19
|
export async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
20
20
|
const uploadable = uploadableCandidates(ctx.scan.candidates);
|
|
21
|
-
|
|
21
|
+
// Doctor needs a boundary after each session. Ordinary backfill retains
|
|
22
|
+
// its existing batching, and scheduled sync does not use this command.
|
|
23
|
+
const plannedBatches = buildBackfillBatches(uploadable);
|
|
24
|
+
const batches = command.deadlineMs !== undefined || command.onProgress
|
|
25
|
+
? plannedBatches.flatMap((batch) => batch.candidates.map((candidate) => ({
|
|
26
|
+
worktree: batch.worktree, candidates: [candidate],
|
|
27
|
+
})))
|
|
28
|
+
: plannedBatches;
|
|
22
29
|
const rawEvidenceBudget = {
|
|
23
30
|
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
24
31
|
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
@@ -34,10 +41,33 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
|
34
41
|
done: tally.done,
|
|
35
42
|
total: uploadable.length,
|
|
36
43
|
});
|
|
44
|
+
const reportProgress = () => {
|
|
45
|
+
if (!command.onProgress)
|
|
46
|
+
return;
|
|
47
|
+
const unresolved = new Set([
|
|
48
|
+
...uploadable.map(candidateCursorKey).filter((key) => !tally.durableCandidateKeys.has(key)),
|
|
49
|
+
...ctx.scan.retryable_candidate_keys,
|
|
50
|
+
]);
|
|
51
|
+
command.onProgress({
|
|
52
|
+
processed: tally.done,
|
|
53
|
+
uploaded: tally.backfilledSessions,
|
|
54
|
+
remaining: unresolved.size + ctx.scan.omitted_candidate_count + ctx.scan.issues
|
|
55
|
+
.filter((issue) => issue.scope === "global")
|
|
56
|
+
.reduce((total, issue) => total + issue.count, 0),
|
|
57
|
+
elapsedMs: Date.now() - ctx.now.getTime(),
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
reportProgress();
|
|
37
61
|
for (const [index, batch] of batches.entries()) {
|
|
38
62
|
await lock.handle.heartbeat();
|
|
63
|
+
if (command.deadlineMs !== undefined && Date.now() >= command.deadlineMs) {
|
|
64
|
+
blockedAt = stoppedAt("backfill time budget reached", index);
|
|
65
|
+
failureReason = "deferred_budget_exhausted";
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
39
68
|
const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
|
|
40
69
|
foldBatchIntoTally(tally, batch, outcome);
|
|
70
|
+
reportProgress();
|
|
41
71
|
consecutiveFailures = outcome.failed ? consecutiveFailures + 1 : 0;
|
|
42
72
|
if (!command.json) {
|
|
43
73
|
writeLine(io.stdout, `Uploaded ${tally.done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
@@ -50,15 +50,29 @@ export async function fixBackfillState(context) {
|
|
|
50
50
|
const started = Date.now();
|
|
51
51
|
const deadline = started + (context.command.backfillBudgetSeconds ?? 900) * 1000;
|
|
52
52
|
let caughtUp = 0;
|
|
53
|
+
let chunks = 0;
|
|
54
|
+
let live = null;
|
|
55
|
+
const elapsed = () => {
|
|
56
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
57
|
+
return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
58
|
+
};
|
|
59
|
+
const receipt = () => `caught up on ${caughtUp} old sessions in ${chunks} chunk${chunks === 1 ? "" : "s"} over ${elapsed()}`;
|
|
53
60
|
let remaining = null;
|
|
54
61
|
const unfinished = (code, reason) => ({
|
|
55
|
-
...needsFix("backfill-complete", code,
|
|
62
|
+
...needsFix("backfill-complete", code, `${receipt()}, ${remaining ?? "unknown"} still to go; ${reason}; run \`cockpit doctor\` again to continue`),
|
|
56
63
|
nextAction: "cockpit doctor",
|
|
57
64
|
});
|
|
58
65
|
const progress = setInterval(() => {
|
|
59
66
|
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
const uploaded = live === null ? null : caughtUp + live.uploaded;
|
|
68
|
+
context.io.stderr.write(live === null
|
|
69
|
+
? `backfill: starting, ${elapsed()}\n`
|
|
70
|
+
: `backfill: ${uploaded} caught up, ${live.processed} processed in this chunk, ${live.remaining} to go, ${elapsed()}\n`);
|
|
71
|
+
console.error("[doctor] backfill-complete progress", JSON.stringify({
|
|
72
|
+
status: live === null ? "starting" : "running", processed: live?.processed ?? null,
|
|
73
|
+
uploaded, caught_up: uploaded, remaining: live?.remaining ?? null,
|
|
74
|
+
chunks, elapsed_seconds: seconds,
|
|
75
|
+
}));
|
|
62
76
|
}, 30_000);
|
|
63
77
|
try {
|
|
64
78
|
while (Date.now() < deadline) {
|
|
@@ -71,20 +85,25 @@ export async function fixBackfillState(context) {
|
|
|
71
85
|
if (Date.now() >= deadline)
|
|
72
86
|
return unfinished("backfill_budget_timeout", "backfill time budget reached");
|
|
73
87
|
const capture = capturedIo(context.io, false);
|
|
88
|
+
live = null;
|
|
89
|
+
chunks += 1;
|
|
74
90
|
const code = await runBackfillCommand({
|
|
75
91
|
homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
|
|
76
92
|
all: true, dryRun: false, yes: true, json: true,
|
|
93
|
+
deadlineMs: deadline,
|
|
94
|
+
onProgress: (progress) => { live = progress; },
|
|
77
95
|
}, capture.io);
|
|
78
96
|
const parsed = parseDoctorBackfillJson(capture.stdout());
|
|
79
97
|
const counts = asRecord(parsed?.counts);
|
|
80
98
|
const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
81
99
|
caughtUp += count(counts?.["backfilled"]) ?? 0;
|
|
82
100
|
remaining = count(counts?.["remaining"]) ?? remaining;
|
|
101
|
+
live = null;
|
|
83
102
|
if (code === 0 && parsed?.status === "complete") {
|
|
84
103
|
// Reporting totals include permanent caps. Preserve their marker
|
|
85
104
|
// note instead of retrying history the collector says is complete.
|
|
86
105
|
const checked = await checkBackfillState(context);
|
|
87
|
-
return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete",
|
|
106
|
+
return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete", receipt() + (checked.status === "ok" ? `; ${checked.message}` : ""));
|
|
88
107
|
}
|
|
89
108
|
const verdict = backfillFixVerdict(parsed, jsonField(capture.stderr(), "failure_reason"));
|
|
90
109
|
if (parsed?.status === "partial" &&
|
|
@@ -94,7 +113,7 @@ export async function fixBackfillState(context) {
|
|
|
94
113
|
return { ...verdict, message: typeof parsed?.failure_reason === "string" ? parsed.failure_reason : "backfill failed without a valid completion receipt" };
|
|
95
114
|
});
|
|
96
115
|
if (row.status === "ok") {
|
|
97
|
-
console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: caughtUp, remaining, elapsed_seconds: Math.floor((Date.now() - started) / 1000) }));
|
|
116
|
+
console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: caughtUp, remaining, chunks, elapsed_seconds: Math.floor((Date.now() - started) / 1000) }));
|
|
98
117
|
return row;
|
|
99
118
|
}
|
|
100
119
|
if (row.code !== "backfill_chunk_pending") {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
2
|
export function parseUsageArgs(args) {
|
|
3
|
-
const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
|
|
3
|
+
const values = parseNamedArgs(args, { allowedFlags: ["--by-topic", "--by-repo", "--person", "--all", "--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--person", "--since", "--until", "--home", "--dashboard-url"] });
|
|
4
|
+
if (values.booleans.has("--by-repo") && values.booleans.has("--by-topic"))
|
|
5
|
+
throw new Error("--by-repo and --by-topic cannot be used together.");
|
|
4
6
|
const action = values.positionals[0] ?? "people";
|
|
5
7
|
if (action !== "people" || values.positionals.length > 1)
|
|
6
8
|
throw new Error("usage takes one verb: people.");
|
|
7
|
-
return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
9
|
+
return { kind: "usage", byTopic: values.booleans.has("--by-topic"), byRepo: values.booleans.has("--by-repo"), person: optionalNonEmpty(values.flags.get("--person")), all: values.booleans.has("--all"), action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
8
10
|
}
|
|
@@ -143,8 +143,9 @@ export const TOWER_COMMAND_HELP = [
|
|
|
143
143
|
[
|
|
144
144
|
"usage",
|
|
145
145
|
[
|
|
146
|
-
"Usage: cockpit usage people [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
|
|
146
|
+
"Usage: cockpit usage people [--by-repo | --by-topic] [--person <email|me>] [--all] [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
|
|
147
147
|
"",
|
|
148
|
+
"--by-topic adds topic rows, including (unlabelled). Choose only one grouping. --by-repo adds project rows under each person (top 10; --all shows every repo). --person me uses your signed-in email.",
|
|
148
149
|
"Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
|
|
149
150
|
"and the input / cache split when the row carries it), and an API list-price equivalent that is",
|
|
150
151
|
"labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
|
|
@@ -102,7 +102,7 @@ export function localCommandHelp(command) {
|
|
|
102
102
|
` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
103
103
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
104
104
|
" cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
|
|
105
|
-
" cockpit usage people [--since
|
|
105
|
+
" cockpit usage people [--by-repo | --by-topic] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
|
|
106
106
|
"",
|
|
107
107
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
108
108
|
].join("\n");
|
|
@@ -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.106");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -12,6 +12,8 @@ export function formatCount(value) {
|
|
|
12
12
|
return String(value);
|
|
13
13
|
}
|
|
14
14
|
export function formatUsageDollars(value) {
|
|
15
|
+
if (value === null)
|
|
16
|
+
return "Unavailable";
|
|
15
17
|
return new Intl.NumberFormat("en-US", {
|
|
16
18
|
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
|
17
19
|
}).format(value);
|
package/dist/commands/usage.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { loadPairedSession } from "../tower-client.js";
|
|
1
2
|
import { formatCount, formatUsageDollars } from "./usage-format.js";
|
|
2
3
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
3
4
|
import { writeLine } from "./cli-io.js";
|
|
4
5
|
export async function runUsage(command, io) {
|
|
5
6
|
const door = await openAgentDoor("usage", command, io);
|
|
6
7
|
const query = new URLSearchParams({ since: command.since });
|
|
8
|
+
if (command.byTopic)
|
|
9
|
+
query.set("groupBy", "task_type");
|
|
10
|
+
if (command.byRepo)
|
|
11
|
+
query.set("groupBy", "repo");
|
|
7
12
|
if (command.until)
|
|
8
13
|
query.set("until", command.until);
|
|
9
14
|
if (command.includeAutomated)
|
|
@@ -12,9 +17,29 @@ export async function runUsage(command, io) {
|
|
|
12
17
|
if (!answer.ok)
|
|
13
18
|
return failAgentDoor(door, "[usage]", answer.reason, answer.detail);
|
|
14
19
|
const body = answer.body;
|
|
20
|
+
if (command.person) {
|
|
21
|
+
const email = command.person === "me" ? (await loadPairedSession("usage", command.homeDir)).email : command.person;
|
|
22
|
+
if (!email)
|
|
23
|
+
return failAgentDoor(door, "[usage]", "caller_email_unavailable", "The paired session has no email. Sign in again or pass --person <email>.");
|
|
24
|
+
body.people = (body.people ?? []).filter((person) => person.email?.toLowerCase() === email.toLowerCase());
|
|
25
|
+
body.coverage = { sessions_labelled: body.people.reduce((total, person) => total + (person.sessions_labelled ?? 0), 0), sessions_extracted: body.people.reduce((total, person) => total + person.sessions_extracted, 0), sessions_observed: body.people.reduce((total, person) => total + person.sessions_observed, 0) };
|
|
26
|
+
}
|
|
27
|
+
if (command.byRepo && body.people?.some((person) => !Array.isArray(person.repos))) {
|
|
28
|
+
return failAgentDoor(door, "[usage]", "repo_grouping_unavailable", "The dashboard returned person totals without project rows. Deploy the dashboard project split before using --by-repo.");
|
|
29
|
+
}
|
|
30
|
+
if (command.byTopic && body.people?.some((person) => !Array.isArray(person.task_types)))
|
|
31
|
+
return failAgentDoor(door, "[usage]", "topic_grouping_unavailable", "The dashboard returned person totals without topic rows. Deploy the dashboard topic split before using --by-topic.");
|
|
15
32
|
if (door.json)
|
|
16
33
|
return emitAgentDoor(door, body);
|
|
17
|
-
|
|
34
|
+
let nameWidth = 20;
|
|
35
|
+
if (command.byRepo || command.byTopic) {
|
|
36
|
+
for (const person of body.people ?? []) {
|
|
37
|
+
const repos = (command.byTopic ? person.task_types : person.repos) ?? [];
|
|
38
|
+
for (const repo of command.all ? repos : repos.slice(0, 10))
|
|
39
|
+
nameWidth = Math.max(nameWidth, repo.repo_label.length + 2);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const widths = [nameWidth, 8, 15, 12, 10, 10, 12, 14];
|
|
18
43
|
const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
|
|
19
44
|
printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
|
|
20
45
|
for (const row of body.people ?? []) {
|
|
@@ -25,9 +50,19 @@ export async function runUsage(command, io) {
|
|
|
25
50
|
`${row.sessions_extracted}/${row.sessions_observed}`,
|
|
26
51
|
...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
|
|
27
52
|
]);
|
|
53
|
+
if (command.byRepo || command.byTopic) {
|
|
54
|
+
const repos = (command.byTopic ? row.task_types : row.repos) ?? [];
|
|
55
|
+
for (const repo of command.all ? repos : repos.slice(0, 10)) {
|
|
56
|
+
printRow([` ${repo.repo_label}`, formatCount(repo.tokens), formatUsageDollars(repo.api_list_price_equivalent_usd), `${repo.extracted_sessions}/${repo.sessions}`, ...(command.detail ? [repo.output, repo.input, repo.cache_read, repo.cache_creation].map(formatCount) : [])]);
|
|
57
|
+
}
|
|
58
|
+
if (!command.all && repos.length > 10)
|
|
59
|
+
writeLine(io.stdout, ` and ${repos.length - 10} more ${command.byTopic ? "topics" : "repos"}`);
|
|
60
|
+
}
|
|
28
61
|
}
|
|
29
62
|
writeLine(io.stdout, "");
|
|
30
63
|
writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
|
|
31
64
|
writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
|
|
65
|
+
if (command.byTopic)
|
|
66
|
+
writeLine(io.stdout, `${body.coverage?.sessions_labelled ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions labelled by topic`);
|
|
32
67
|
return 0;
|
|
33
68
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.106",
|
|
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.27",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.35",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.43"
|
|
33
33
|
}
|
|
34
34
|
}
|