@bli-cockpit/cli 0.2.192 → 0.2.194

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.
@@ -15,40 +15,29 @@
15
15
  */
16
16
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
17
17
  /**
18
- * Every corpus. Kept here so an unknown kind is refused BEFORE a round trip,
19
- * and mirrors `SEARCH_KINDS` in the dashboard — nothing in the type system ties
20
- * the two lists together.
18
+ * What `--kind` names is the SERVER's to say (BLI-13027). This used to be a copy
19
+ * of the dashboard's kind list that refused anything it did not hold, so an
20
+ * installed CLI told a person that Scout did not exist the day Scout shipped.
21
+ * Now the CLI forwards the words, and the door answers `unknown_kind` with the
22
+ * list this account is offered. `cockpit search --categories` prints that list.
21
23
  */
22
- export const SEARCH_KINDS = [
23
- "doc",
24
- "msg",
25
- "issue",
26
- "note",
27
- "mail",
28
- "cal",
29
- "memory",
30
- ];
31
24
  export function parseSearchArgs(args) {
32
25
  const values = parseNamedArgs(args, {
33
- allowedFlags: ["--home", "--dashboard-url", "--kind", "--limit", "--json"],
26
+ allowedFlags: ["--home", "--dashboard-url", "--kind", "--limit", "--json", "--categories"],
34
27
  valueFlags: ["--home", "--dashboard-url", "--kind", "--limit"],
35
28
  });
36
29
  const query = values.positionals.join(" ").trim();
37
- if (query.length === 0) {
30
+ const categories = values.booleans.has("--categories");
31
+ if (query.length === 0 && !categories) {
38
32
  throw new Error('search needs something to search for, e.g. `cockpit search "storage ceiling"`.');
39
33
  }
40
34
  const kindFlag = optionalNonEmpty(values.flags.get("--kind"));
41
35
  let kinds;
42
36
  if (kindFlag) {
43
37
  kinds = [];
44
- // BLI-12947: `ticket` is the new spelling of the `issue` kind (input only).
45
- for (const raw of kindFlag.split(",").map((entry) => entry.trim()).filter(Boolean)) {
46
- const part = raw === "ticket" ? "issue" : raw;
47
- if (!SEARCH_KINDS.includes(part)) {
48
- throw new Error(`search --kind must be one or more of ${SEARCH_KINDS.join(", ")} — not "${raw}".`);
49
- }
50
- if (!kinds.includes(part))
51
- kinds.push(part);
38
+ for (const raw of kindFlag.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean)) {
39
+ if (!kinds.includes(raw))
40
+ kinds.push(raw);
52
41
  }
53
42
  if (kinds.length === 0)
54
43
  kinds = undefined;
@@ -56,6 +45,7 @@ export function parseSearchArgs(args) {
56
45
  return {
57
46
  kind: "search",
58
47
  query,
48
+ ...(categories ? { categories: true } : {}),
59
49
  ...(kinds ? { kinds } : {}),
60
50
  limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
61
51
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -0,0 +1,31 @@
1
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
+ import { SPEED_PAGE_NAMES } from "./speed-pages.js";
3
+ /** The most runs one page gets; a speed check is a handful of reads, not a load test. */
4
+ export const SPEED_MAX_RUNS = 50;
5
+ /** `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033). */
6
+ export function parseSpeedArgs(args) {
7
+ const values = parseNamedArgs(args, {
8
+ allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url"],
9
+ valueFlags: ["--page", "--runs", "--home", "--dashboard-url"],
10
+ });
11
+ if (values.positionals.length) {
12
+ throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
13
+ }
14
+ const pages = (values.flagValues.get("--page") ?? []).map((name) => name.trim().toLowerCase());
15
+ const unknown = pages.filter((name) => !SPEED_PAGE_NAMES.includes(name));
16
+ if (unknown.length) {
17
+ throw new Error(`Unknown speed page: ${unknown.join(", ")}. Pages: ${SPEED_PAGE_NAMES.join(", ")}.`);
18
+ }
19
+ const runs = optionalPositiveInteger(values.flags.get("--runs"), "--runs");
20
+ if (runs !== undefined && runs > SPEED_MAX_RUNS) {
21
+ throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
22
+ }
23
+ return {
24
+ kind: "speed",
25
+ pages: [...new Set(pages)],
26
+ runs,
27
+ json: values.booleans.has("--json"),
28
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
29
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
30
+ };
31
+ }
@@ -33,11 +33,12 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
33
33
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
34
34
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
35
35
  export { parseGithubArgs } from "./local-args-tower-github.js";
36
- export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
36
+ export { parseSearchArgs } from "./local-args-tower-search.js";
37
37
  export { parseModelsArgs } from "./local-args-tower-models.js";
38
38
  export { parseMailArgs } from "./local-args-tower-mail.js";
39
39
  export { parseCalArgs } from "./local-args-tower-cal.js";
40
40
  export { parseSetupArgs } from "./local-args-tower-setup.js";
41
+ export { parseSpeedArgs } from "./local-args-tower-speed.js";
41
42
  export { parseUsageArgs } from "./local-args-tower-usage.js";
42
43
  export { parseLogsArgs } from "./local-args-tower-logs.js";
43
44
  export { parseBrainArgs } from "./local-args-tower-brain.js";
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
19
19
  import { parseRootsArgs } from "./roots.js";
20
20
  import { SURFACE_ALIASES } from "./local-help.js";
21
21
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
22
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
23
23
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
24
24
  // `local-auth.ts` import it from here — so it stays exported from this address
25
25
  // even though it now lives next door. The same goes for the four names the
@@ -138,6 +138,8 @@ export function parseLocalArgs(argv) {
138
138
  return parseSetupArgs(argv.slice(1));
139
139
  case "usage":
140
140
  return parseUsageArgs(argv.slice(1));
141
+ case "speed":
142
+ return parseSpeedArgs(argv.slice(1));
141
143
  case "logs":
142
144
  return parseLogsArgs(argv.slice(1));
143
145
  case "brain":
@@ -11,15 +11,6 @@
11
11
  * Every string moved verbatim. Help output is what an intern pastes back when
12
12
  * something breaks, so it is a user-visible contract like any other.
13
13
  */
14
- import { SEARCH_KINDS } from "./local-args-tower-search.js";
15
- /**
16
- * The corpora `cockpit search` can name, spelled the way `--kind` takes them.
17
- * Read off `SEARCH_KINDS` rather than typed out: the help string said five for
18
- * the two months `mail` and `cal` were searchable, so a person was told the
19
- * door was narrower than it is (BLI-4048).
20
- */
21
- const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
22
- const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
23
14
  /** One entry per Tower noun, in the order `cockpit --help` lists them. */
24
15
  export const TOWER_COMMAND_HELP = [
25
16
  [
@@ -184,6 +175,24 @@ export const TOWER_COMMAND_HELP = [
184
175
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
185
176
  ],
186
177
  ],
178
+ [
179
+ "speed",
180
+ [
181
+ "Usage: cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
182
+ "",
183
+ "Times every Tower rail page's data door from this terminal, with this machine's device token.",
184
+ "Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
185
+ "(nearest rank). Also the HTTP status, the response size and the door's own Server-Timing total.",
186
+ "Slowest first. A page your account may not open (Careers for a member) reads \"not permitted\", not as a failure.",
187
+ "--page <name> limits the run to one page; repeat it for several. Names: today, meeting-notes, careers, scout,",
188
+ "messages, mail, docs, memory, tickets, setup, usage, calendar, workbook, settings.",
189
+ "--runs <n> sets the requests per page (default 5, at most 50).",
190
+ "--json writes one object to stdout ({ ok, runs, pages: [...] }); every log line stays on stderr.",
191
+ "It times the DATA DOOR the page opens on, not the page's HTML or paint, and it never uses the local read cache.",
192
+ "Exits 1 when any page failed; a page that is not permitted does not fail the run.",
193
+ "The same measurement is the speed_read MCP tool.",
194
+ ],
195
+ ],
187
196
  [
188
197
  "usage",
189
198
  [
@@ -207,10 +216,11 @@ export const TOWER_COMMAND_HELP = [
207
216
  [
208
217
  "search",
209
218
  [
210
- `Usage: cockpit search "<words>" [--kind ${SEARCH_KIND_LIST}] [--limit <n>] [--json]`,
219
+ 'Usage: cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--json]',
220
+ " cockpit search --categories [--json]",
211
221
  "",
212
- `One bar over ${SEARCH_CORPORA_COUNT} corpora: documents, messages, issues, meeting notes, mail, calendar`,
213
- "events and memory.",
222
+ "One bar over every corpus Tower holds: tickets, documents, meeting notes, messages, mail,",
223
+ "calendar events, memory, Scout and (where you may read it) Careers.",
214
224
  "It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
215
225
  "read here is the same row, the same ranking and the same snippet a person sees in Tower.",
216
226
  "",
@@ -219,14 +229,16 @@ export const TOWER_COMMAND_HELP = [
219
229
  "Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
220
230
  "websearch parser, which shrugs at anything a person can type instead of raising.",
221
231
  "",
222
- `--kind narrows to one or more corpora, comma separated: ${SEARCH_KIND_LIST}.`,
223
- `Omit it and all ${SEARCH_CORPORA_COUNT} are searched.`,
232
+ "--kind narrows to one or more categories, comma separated (ticket works for issue).",
233
+ "--categories prints the list Tower offers THIS account, in the order the browser's Command-K",
234
+ "chips use, so the names are never a guess; an unknown --kind is refused by the door with that list.",
235
+ "Omit --kind and every category is searched.",
224
236
  "--limit caps the number of results (default 20, maximum 50).",
225
237
  "--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
226
238
  "",
227
- `Every result is scoped by what YOU may read: ${SEARCH_CORPORA_COUNT - 1} of the ${SEARCH_CORPORA_COUNT} corpora are`,
228
- "searched on your own database session, so a document or a channel you cannot open is not in",
229
- "the list; memory runs on the service role behind the memory doors' own gate.",
239
+ "Every result is scoped by what YOU may read: most corpora (and Careers always) are searched on",
240
+ "your own database session, so a document, a channel or an applicant you cannot open is not in",
241
+ "the list; memory and Scout run on the service role behind their own gates.",
230
242
  "",
231
243
  "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
232
244
  "those are different facts and folding them together would let a broken search read as silence.",
@@ -8,7 +8,6 @@
8
8
  * user-visible contract like any other.
9
9
  */
10
10
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
11
- import { SEARCH_KINDS } from "./local-args-tower-search.js";
12
11
  import { localSubcommandHelp } from "./local-help-commands.js";
13
12
  /**
14
13
  * The Tower PAGE names a person reads in the rail, each routed to the CLI noun
@@ -84,6 +83,7 @@ export const rootCommandNames = new Set([
84
83
  "rename",
85
84
  "setup",
86
85
  "usage",
86
+ "speed",
87
87
  "logs",
88
88
  "brain",
89
89
  ...SURFACE_ALIASES.keys(),
@@ -136,7 +136,8 @@ export function localCommandHelp(command) {
136
136
  " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
137
137
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
138
138
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
139
- ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
139
+ ` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
140
+ " cockpit search --categories [--dashboard-url <url>] [--json]",
140
141
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
141
142
  " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
142
143
  " cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
@@ -153,6 +154,7 @@ export function localCommandHelp(command) {
153
154
  " cockpit calendar [same verbs and flags as cockpit cal]",
154
155
  " cockpit messages [same verbs and flags as cockpit msg]",
155
156
  " cockpit meeting-notes [same verbs and flags as cockpit notes]",
157
+ " cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
156
158
  " cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
157
159
  "",
158
160
  `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.`,
@@ -52,6 +52,7 @@ import { runCareers } from "./careers.js";
52
52
  import { runRename } from "./rename-scope.js";
53
53
  import { runSetup } from "./setup.js";
54
54
  import { runUsage } from "./usage.js";
55
+ import { runSpeed } from "./speed.js";
55
56
  import { runLogs } from "./logs.js";
56
57
  import { runBrain } from "./brain.js";
57
58
  import { parseLocalArgs } from "./local-args.js";
@@ -249,6 +250,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
249
250
  return await runRename(command, io);
250
251
  case "setup": return await runSetup(command, io);
251
252
  case "usage": return await runUsage(command, io);
253
+ case "speed": return await runSpeed(command, io);
252
254
  case "logs": return await runLogs(command, io);
253
255
  case "brain": return await runBrain(command, io);
254
256
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.192");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.194");
25
25
  return 0;
26
26
  }
27
27
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `cockpit search` — one bar over five corpora, in a terminal (BLI-3728).
2
+ * `cockpit search` — one bar over every corpus, in a terminal (BLI-3728).
3
3
  *
4
4
  * The SAME door the browser's search bar presses: `GET /api/search`, opted
5
5
  * into the collector device token in `route-caller-census.test.ts`. So a
@@ -42,10 +42,15 @@ const KIND_LABELS = {
42
42
  mail: "MAIL",
43
43
  cal: "CALENDAR",
44
44
  memory: "MEMORY",
45
+ scout: "SCOUT",
46
+ careers: "CAREERS",
45
47
  };
46
- const KIND_ORDER = ["doc", "msg", "issue", "note", "mail", "cal", "memory"];
48
+ const KIND_ORDER = ["doc", "msg", "issue", "note", "mail", "cal", "memory", "scout", "careers"];
47
49
  export async function runSearch(command, io) {
48
50
  const door = await openAgentDoor("search", command, io);
51
+ // BLI-13027: the list the browser's Command-K chips are drawn from.
52
+ if (command.categories)
53
+ return await runCategories(door);
49
54
  const params = new URLSearchParams({ q: command.query });
50
55
  if (command.kinds && command.kinds.length > 0)
51
56
  params.set("kinds", command.kinds.join(","));
@@ -64,6 +69,28 @@ export async function runSearch(command, io) {
64
69
  return emitAgentDoor(door, body);
65
70
  return render(door, body);
66
71
  }
72
+ async function runCategories(door) {
73
+ const answer = await askAgentDoor(door, {
74
+ path: "/api/search?categories=1",
75
+ method: "GET",
76
+ label: "search categories",
77
+ timeoutMs: READ_DEADLINE_MS,
78
+ });
79
+ if (!answer.ok)
80
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
81
+ const body = answer.body;
82
+ if (door.json)
83
+ return emitAgentDoor(door, body);
84
+ const categories = body.categories ?? [];
85
+ writeLine(door.io.stdout, `${body.everything?.label ?? "Everything"} (searched when --kind is left out)`);
86
+ for (const entry of categories) {
87
+ const aliases = (entry.aliases ?? []).length > 0 ? ` (also: ${(entry.aliases ?? []).join(", ")})` : "";
88
+ writeLine(door.io.stdout, ` ${entry.kind} ${entry.label}${aliases}`);
89
+ }
90
+ writeLine(door.io.stdout, "");
91
+ writeLine(door.io.stdout, `${categories.length} categories. Name them with --kind, comma separated.`);
92
+ return 0;
93
+ }
67
94
  function render(door, body) {
68
95
  const hits = body.hits ?? [];
69
96
  const failures = body.failures ?? {};
@@ -76,7 +103,10 @@ function render(door, body) {
76
103
  writeLine(door.io.stdout, "Nothing matched in the corpora that answered.");
77
104
  }
78
105
  }
79
- for (const kind of KIND_ORDER) {
106
+ // Known kinds in the usual order, then any kind this CLI has never heard of
107
+ // (a newer door than this build): printed, not swallowed.
108
+ const unknownKinds = [...new Set(hits.map((hit) => hit.kind))].filter((kind) => !KIND_ORDER.includes(kind));
109
+ for (const kind of [...KIND_ORDER, ...unknownKinds]) {
80
110
  const group = hits.filter((hit) => hit.kind === kind);
81
111
  if (group.length === 0)
82
112
  continue;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The arithmetic and the layout behind `cockpit speed` (BLI-13033). Pure: no
3
+ * clock, no network, so the timing math, the ranking and the "not permitted"
4
+ * rule are all tested with plain numbers.
5
+ */
6
+ /** 401 is a bad token (a failure to fix); 403 is a page this caller may not open. */
7
+ export function classifyStatus(status) {
8
+ if (status === 403)
9
+ return "not_permitted";
10
+ if (status !== null && status >= 200 && status < 300)
11
+ return "ok";
12
+ return "failed";
13
+ }
14
+ /** Nearest-rank percentile over unsorted values. 0 < p <= 100. Null on no values. */
15
+ export function percentile(values, p) {
16
+ if (values.length === 0)
17
+ return null;
18
+ const sorted = [...values].sort((a, b) => a - b);
19
+ const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
20
+ return sorted[Math.min(rank, sorted.length) - 1] ?? null;
21
+ }
22
+ /** `total;dur=123.4` out of a Server-Timing header. */
23
+ export function serverTotalMs(header) {
24
+ if (!header)
25
+ return null;
26
+ for (const part of header.split(",")) {
27
+ const [name, ...params] = part.trim().split(";");
28
+ if (name?.trim() !== "total")
29
+ continue;
30
+ for (const param of params) {
31
+ const [key, value] = param.trim().split("=");
32
+ if (key === "dur" && value !== undefined && Number.isFinite(Number(value)))
33
+ return Number(value);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ const round1 = (value) => (value === null ? null : Math.round(value * 10) / 10);
39
+ export function summarizeSamples(page, samples) {
40
+ const base = { page: page.name, label: page.label, door: page.path, ...(page.note ? { note: page.note } : {}) };
41
+ const last = samples[samples.length - 1];
42
+ const good = samples.filter((sample) => classifyStatus(sample.status) === "ok");
43
+ const denied = samples.find((sample) => classifyStatus(sample.status) === "not_permitted");
44
+ if (good.length === 0) {
45
+ const first = samples[0];
46
+ const outcome = denied ? "not_permitted" : "failed";
47
+ return {
48
+ ...base,
49
+ outcome,
50
+ cold_ms: null,
51
+ p50_ms: null,
52
+ p95_ms: null,
53
+ runs: samples.length,
54
+ failed_runs: denied ? 0 : samples.length,
55
+ http_status: (denied ?? first)?.status ?? null,
56
+ bytes: null,
57
+ server_timing: null,
58
+ server_total_ms: null,
59
+ reason: denied
60
+ ? "not permitted for this account"
61
+ : (first?.failure ?? (first?.status === null || first === undefined ? "no_response" : `http_${first.status}`)),
62
+ };
63
+ }
64
+ const cold = samples[0] && classifyStatus(samples[0].status) === "ok" ? samples[0] : null;
65
+ const warm = good.filter((sample) => sample !== cold);
66
+ const timed = (warm.length > 0 ? warm : good).map((sample) => sample.ms);
67
+ const lastGood = good[good.length - 1] ?? last;
68
+ return {
69
+ ...base,
70
+ outcome: "ok",
71
+ cold_ms: round1(cold ? cold.ms : null),
72
+ p50_ms: round1(percentile(timed, 50)),
73
+ p95_ms: round1(percentile(timed, 95)),
74
+ runs: samples.length,
75
+ failed_runs: samples.length - good.length,
76
+ http_status: lastGood?.status ?? null,
77
+ bytes: lastGood?.bytes ?? null,
78
+ server_timing: lastGood?.serverTiming ?? null,
79
+ server_total_ms: serverTotalMs(lastGood?.serverTiming ?? null),
80
+ };
81
+ }
82
+ /**
83
+ * Slowest first by median. Pages that answered come first, then pages that
84
+ * failed (a failure is something to fix, so it sits above the pages that were
85
+ * simply not open to this account), then pages not permitted.
86
+ */
87
+ export function rankPages(pages) {
88
+ const tier = (page) => (page.outcome === "ok" ? 0 : page.outcome === "failed" ? 1 : 2);
89
+ return [...pages].sort((a, b) => {
90
+ if (tier(a) !== tier(b))
91
+ return tier(a) - tier(b);
92
+ return (b.p50_ms ?? 0) - (a.p50_ms ?? 0) || a.page.localeCompare(b.page);
93
+ });
94
+ }
95
+ function pad(value, width, right = false) {
96
+ return right ? value.padStart(width) : value.padEnd(width);
97
+ }
98
+ function ms(value) {
99
+ return value === null ? "-" : `${Math.round(value)} ms`;
100
+ }
101
+ function size(bytes) {
102
+ if (bytes === null)
103
+ return "-";
104
+ if (bytes < 1024)
105
+ return `${bytes} B`;
106
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
107
+ }
108
+ /** The ranked table a person reads. Already ranked: this lays out, it does not sort. */
109
+ export function renderSpeedTable(ranked, runs) {
110
+ const rows = ranked.map((page, index) => {
111
+ if (page.outcome === "ok") {
112
+ return [
113
+ String(index + 1),
114
+ page.page,
115
+ ms(page.cold_ms),
116
+ ms(page.p50_ms),
117
+ ms(page.p95_ms),
118
+ String(page.http_status ?? "-"),
119
+ size(page.bytes),
120
+ page.server_total_ms === null ? "-" : ms(page.server_total_ms),
121
+ ];
122
+ }
123
+ const word = page.outcome === "not_permitted" ? "not permitted" : `FAILED (${page.reason ?? "unknown"})`;
124
+ return [String(index + 1), page.page, word, "", "", String(page.http_status ?? "-"), "-", "-"];
125
+ });
126
+ const head = ["#", "PAGE", "COLD", "P50", "P95", "HTTP", "BYTES", "SERVER"];
127
+ const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
128
+ const line = (cells) => cells.map((cell, column) => pad(cell, widths[column] ?? 0, column >= 2 && column !== 5)).join(" ").trimEnd();
129
+ return [
130
+ line(head),
131
+ ...rows.map(line),
132
+ "",
133
+ `${runs} run${runs === 1 ? "" : "s"} per page: the first is cold, p50 and p95 are over the warm runs${runs === 1 ? " (only the cold run was made)" : ""}. SERVER is the door's own Server-Timing total.`,
134
+ ];
135
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Which door each rail page opens on (BLI-13033).
3
+ *
4
+ * `cockpit speed` times the DATA DOOR of every rail page: the API read the
5
+ * page's first useful content comes from, or (where the page reads the
6
+ * database straight from the server) the nearest door that reads the same
7
+ * rows. It does not time the page's HTML, hydration or paint, so a number here
8
+ * is the floor of what a person waits, never more than that.
9
+ *
10
+ * Order is the rail's order (`ck-rail.tsx` `NAV_ITEMS`). The MCP twin
11
+ * (`packages/bli-cockpit-mcp/src/speed-tools.ts`) carries its own copy, and a
12
+ * test in that package fails when the two lists drift.
13
+ */
14
+ /** The pages in rail order. `memoryContainer` is the tag `cockpit memory profile` would use here. */
15
+ export function speedPages(memoryContainer) {
16
+ return [
17
+ { name: "today", label: "Today (JARVIS home)", path: "/api/jarvis/brief" },
18
+ { name: "meeting-notes", label: "Meeting notes", path: "/api/notes/library" },
19
+ { name: "careers", label: "Careers", path: "/api/careers/applications" },
20
+ { name: "scout", label: "Scout", path: "/api/cockpit/scout" },
21
+ { name: "messages", label: "Messages", path: "/api/msg/channels" },
22
+ { name: "mail", label: "Mail", path: "/api/mail/inbox" },
23
+ { name: "docs", label: "Docs", path: "/api/docs/tree" },
24
+ {
25
+ name: "memory",
26
+ label: "Memory",
27
+ path: `/api/memory/profile?container=${encodeURIComponent(memoryContainer)}`,
28
+ note: "the page reads agent_memories on the server; the profile door is the nearest read",
29
+ },
30
+ { name: "tickets", label: "Tickets", path: "/api/work/issues" },
31
+ { name: "setup", label: "Setup", path: "/api/setup" },
32
+ { name: "usage", label: "Usage", path: "/api/usage/people?since=30d" },
33
+ { name: "calendar", label: "Calendar", path: "/api/cal/week" },
34
+ { name: "workbook", label: "Workbook", path: "/api/cockpit/workbook" },
35
+ { name: "settings", label: "Settings", path: "/api/settings/personal" },
36
+ ];
37
+ }
38
+ export const SPEED_PAGE_NAMES = speedPages("x").map((page) => page.name);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `cockpit speed` (BLI-13033) — how long each Tower rail page's data door takes,
3
+ * measured from this terminal with this machine's own device token.
4
+ *
5
+ * For every page: one request per run, the first reported as cold and the rest
6
+ * as warm, then p50, p95, the HTTP status, the response size and the door's
7
+ * own `Server-Timing` header. A page this account may not open (Careers for a
8
+ * member) reads "not permitted", which is an answer, not a failure.
9
+ *
10
+ * What a number is: wall-clock from sending the request to the last byte of the
11
+ * body, on THIS machine, so it includes the network between here and Tower. It
12
+ * is the data door, not the page's HTML or paint (`speed-pages.ts` says which
13
+ * door stands for which page). Reads only, and never the local read cache or
14
+ * the warm-connection route: a speed number that a cache answered would be a
15
+ * lie about Tower.
16
+ *
17
+ * stdout is `--json`'s alone; every log line goes to stderr, metadata only (a
18
+ * page name, a status, a millisecond count). The device token is attached by
19
+ * the shared Tower client and never printed.
20
+ */
21
+ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
22
+ import { writeLine } from "./cli-io.js";
23
+ import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
24
+ import { speedPages } from "./speed-pages.js";
25
+ import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
26
+ export const DEFAULT_SPEED_RUNS = 5;
27
+ const REQUEST_DEADLINE_MS = 60_000;
28
+ export async function runSpeed(command, io, deps = {}) {
29
+ const now = deps.now ?? (() => performance.now());
30
+ const log = (line) => writeLine(io.stderr, line);
31
+ const session = await loadPairedSession("speed", command.homeDir);
32
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
33
+ const runs = command.runs ?? DEFAULT_SPEED_RUNS;
34
+ const everyPage = speedPages(resolveContainerTag({ cwd: process.cwd() }).containerTag);
35
+ const chosen = command.pages.length > 0
36
+ ? everyPage.filter((page) => command.pages.includes(page.name))
37
+ : everyPage;
38
+ const results = [];
39
+ for (const page of chosen) {
40
+ const samples = [];
41
+ for (let run = 0; run < runs; run += 1) {
42
+ const sample = await timeOneRead(page, { dashboardUrl, deviceToken: session.device_token, io, now, log });
43
+ samples.push(sample);
44
+ // A page this account may not open answers the same way every time.
45
+ if (classifyStatus(sample.status) === "not_permitted")
46
+ break;
47
+ }
48
+ const summary = summarizeSamples(page, samples);
49
+ results.push(summary);
50
+ log(`[speed cli] page measured ${JSON.stringify({
51
+ page: summary.page,
52
+ outcome: summary.outcome,
53
+ http_status: summary.http_status,
54
+ runs: samples.length,
55
+ failed_runs: summary.failed_runs,
56
+ p50_ms: summary.p50_ms,
57
+ ...(summary.reason ? { reason: summary.reason } : {}),
58
+ })}`);
59
+ }
60
+ const ranked = rankPages(results);
61
+ const failed = ranked.filter((page) => page.outcome === "failed").length;
62
+ const notPermitted = ranked.filter((page) => page.outcome === "not_permitted").length;
63
+ log(`[speed cli] done ${JSON.stringify({
64
+ pages: ranked.length,
65
+ measured: ranked.length - failed - notPermitted,
66
+ failed,
67
+ not_permitted: notPermitted,
68
+ runs,
69
+ })}`);
70
+ if (command.json) {
71
+ writeLine(io.stdout, JSON.stringify({
72
+ ok: failed === 0,
73
+ measured_at: new Date().toISOString(),
74
+ dashboard_url: dashboardUrl,
75
+ runs,
76
+ pages: ranked,
77
+ }));
78
+ }
79
+ else {
80
+ for (const line of renderSpeedTable(ranked, runs))
81
+ writeLine(io.stdout, line);
82
+ }
83
+ return failed === 0 ? 0 : 1;
84
+ }
85
+ async function timeOneRead(page, context) {
86
+ const startedAt = context.now();
87
+ const answer = await towerRequest({
88
+ dashboardUrl: context.dashboardUrl,
89
+ path: page.path,
90
+ method: "GET",
91
+ deviceToken: context.deviceToken,
92
+ fetch: context.io.fetch,
93
+ label: `speed-${page.name}`,
94
+ timeoutMs: REQUEST_DEADLINE_MS,
95
+ // The client's per-request success trace would print 70 lines here; the
96
+ // failure lines stay.
97
+ log: (line) => {
98
+ if (!line.startsWith("[tower client] responded"))
99
+ context.log(line);
100
+ },
101
+ });
102
+ if (!answer.ok) {
103
+ return {
104
+ status: null,
105
+ ms: context.now() - startedAt,
106
+ bytes: 0,
107
+ serverTiming: null,
108
+ failure: `${answer.reason}: ${towerFailureDetail(answer.reason, answer.detail)}`.slice(0, 120),
109
+ };
110
+ }
111
+ let bytes = 0;
112
+ try {
113
+ bytes = (await answer.response.arrayBuffer()).byteLength;
114
+ }
115
+ catch {
116
+ return {
117
+ status: answer.response.status,
118
+ ms: context.now() - startedAt,
119
+ bytes: 0,
120
+ serverTiming: null,
121
+ failure: "body_read_failed",
122
+ };
123
+ }
124
+ return {
125
+ status: answer.response.status,
126
+ ms: context.now() - startedAt,
127
+ bytes,
128
+ serverTiming: answer.response.headers.get("server-timing"),
129
+ };
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.192",
3
+ "version": "0.2.194",
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.57",
31
- "@bli-cockpit/mcp": "0.1.104",
31
+ "@bli-cockpit/mcp": "0.1.106",
32
32
  "@bli-cockpit/telemetry-core": "0.1.66"
33
33
  },
34
34
  "repository": {