@bli-cockpit/cli 0.2.42 → 0.2.44

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.
@@ -13,12 +13,16 @@
13
13
  * asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
14
14
  */
15
15
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
16
+ import { renderBriefStatus } from "./ops-render.js";
16
17
  import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
17
18
  const REQUEST_DEADLINE_MS = 30_000;
18
19
  export async function runBrief(command, io) {
19
20
  const session = await loadPairedSession("brief", command.homeDir);
20
21
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
21
22
  const startedAt = Date.now();
23
+ if (command.action === "status") {
24
+ return runBriefStatus(command, io, dashboardUrl, session.device_token, startedAt);
25
+ }
22
26
  const result = await towerJsonRequest({
23
27
  dashboardUrl,
24
28
  path: `/api/jarvis/brief${queryFor(command)}`,
@@ -63,6 +67,60 @@ export async function runBrief(command, io) {
63
67
  })}`);
64
68
  return 0;
65
69
  }
70
+ /**
71
+ * `cockpit brief status` (BLI-3462) — why a brief was or was not delivered.
72
+ *
73
+ * The reason is the server's, from the closed set in
74
+ * `lib/jarvis/delivery-status.ts`, computed by the same code the Slack DM cron
75
+ * delivers with. This prints it and picks an exit code: `delivered` is 0,
76
+ * everything else is 1, because "your brief did not arrive" is a result a
77
+ * script should be able to act on — including the two that are perfectly
78
+ * normal (`not_monday`, `not_the_1st`), which exit 1 with their own sentence
79
+ * rather than pretending a delivery happened.
80
+ */
81
+ async function runBriefStatus(command, io, dashboardUrl, deviceToken, startedAt) {
82
+ const params = new URLSearchParams();
83
+ if (command.subject)
84
+ params.set("who", command.subject);
85
+ if (command.render)
86
+ params.set("render", "1");
87
+ const query = params.toString();
88
+ const result = await towerJsonRequest({
89
+ dashboardUrl,
90
+ path: `/api/ops/brief-status${query ? `?${query}` : ""}`,
91
+ deviceToken,
92
+ fetch: io.fetch,
93
+ method: "GET",
94
+ label: "brief-status",
95
+ timeoutMs: REQUEST_DEADLINE_MS,
96
+ log: (line) => writeLine(io.stderr, line),
97
+ });
98
+ if (!result.ok) {
99
+ writeFailure(command, io, result.reason, result.detail);
100
+ return 1;
101
+ }
102
+ const payload = result.body;
103
+ if (command.json) {
104
+ writeLine(io.stdout, JSON.stringify(payload));
105
+ }
106
+ else {
107
+ const styled = colorEnabled(io);
108
+ for (const line of renderBriefStatus(payload, (text) => dim(text, styled))) {
109
+ writeLine(io.stdout, line);
110
+ }
111
+ }
112
+ const reason = payload.status?.reason ?? "unknown";
113
+ writeLine(io.stderr, `[brief cli] status read ${JSON.stringify({
114
+ reason,
115
+ subject: command.subject ? "selected" : "caller",
116
+ owning_cadence: payload.status?.owningCadence ?? null,
117
+ has_slack_identity: payload.status?.slack?.hasIdentity ?? null,
118
+ rendered: Boolean(command.render),
119
+ read_error: payload.status?.readError ?? null,
120
+ elapsed_ms: Date.now() - startedAt,
121
+ })}`);
122
+ return reason === "delivered" ? 0 : 1;
123
+ }
66
124
  function queryFor(command) {
67
125
  const params = new URLSearchParams();
68
126
  if (command.subject)
@@ -53,6 +53,10 @@ export function parseLocalArgs(argv) {
53
53
  return parseModelArgs(argv.slice(1));
54
54
  case "scout":
55
55
  return parseScoutArgs(argv.slice(1));
56
+ case "ops":
57
+ return parseOpsArgs(argv.slice(1));
58
+ case "slack":
59
+ return parseSlackArgs(argv.slice(1));
56
60
  case "settings":
57
61
  return parseSettingsArgs(argv.slice(1));
58
62
  case "team":
@@ -945,6 +949,143 @@ function parseScoutArgs(args) {
945
949
  }
946
950
  return { kind: "scout", action: rawAction, experimentRef, ...base };
947
951
  }
952
+ /**
953
+ * `cockpit ops status [--job <id>] [--skips]` and
954
+ * `cockpit ops recompile --person <p> [--dry-run]` (BLI-3462).
955
+ *
956
+ * There is deliberately **no `--cadence`** on `recompile`. The compile path
957
+ * behind it (`writePageAgain` → `publishPage`) takes no cadence and writes a
958
+ * daily; accepting the flag and dropping it is the silent breakage BLI-2490
959
+ * forbids, and threading cadence through that path is its own change. Ask for a
960
+ * weekly or a monthly with the compile script, which does support it.
961
+ */
962
+ function parseOpsArgs(args) {
963
+ const values = parseNamedArgs(args, {
964
+ allowedFlags: [
965
+ "--home",
966
+ "--dashboard-url",
967
+ "--job",
968
+ "--skips",
969
+ "--person",
970
+ "--dry-run",
971
+ "--json",
972
+ ],
973
+ valueFlags: ["--home", "--dashboard-url", "--job", "--person"],
974
+ });
975
+ if (values.positionals.length > 1) {
976
+ throw new Error("ops accepts one action: status or recompile.");
977
+ }
978
+ const rawAction = values.positionals[0] ?? "status";
979
+ if (rawAction !== "status" && rawAction !== "recompile") {
980
+ throw new Error("ops action must be status or recompile.");
981
+ }
982
+ const base = {
983
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
984
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
985
+ json: values.booleans.has("--json"),
986
+ };
987
+ if (rawAction === "status") {
988
+ return {
989
+ kind: "ops",
990
+ action: "status",
991
+ job: optionalNonEmpty(values.flags.get("--job")),
992
+ skips: values.booleans.has("--skips"),
993
+ ...base,
994
+ };
995
+ }
996
+ const person = optionalNonEmpty(values.flags.get("--person"));
997
+ if (!person) {
998
+ throw new Error("ops recompile needs --person <email|name|id>. Nothing is recompiled by default, on purpose.");
999
+ }
1000
+ return {
1001
+ kind: "ops",
1002
+ action: "recompile",
1003
+ person,
1004
+ dryRun: values.booleans.has("--dry-run"),
1005
+ ...base,
1006
+ };
1007
+ }
1008
+ /** The workspaces Cockpit collects. A typo is refused here rather than searched for. */
1009
+ export const SLACK_WORKSPACE_KEYS = ["bli", "blue_pearl"];
1010
+ /**
1011
+ * `cockpit slack coverage [--workspace <key>] [--stale-only]` and
1012
+ * `cockpit slack read [--person|--channel|--query|--since|--until|--limit]`
1013
+ * (BLI-3462).
1014
+ */
1015
+ function parseSlackArgs(args) {
1016
+ const values = parseNamedArgs(args, {
1017
+ allowedFlags: [
1018
+ "--home",
1019
+ "--dashboard-url",
1020
+ "--workspace",
1021
+ "--stale-only",
1022
+ "--person",
1023
+ "--channel",
1024
+ "--query",
1025
+ "--since",
1026
+ "--until",
1027
+ "--limit",
1028
+ "--json",
1029
+ ],
1030
+ valueFlags: [
1031
+ "--home",
1032
+ "--dashboard-url",
1033
+ "--workspace",
1034
+ "--person",
1035
+ "--channel",
1036
+ "--query",
1037
+ "--since",
1038
+ "--until",
1039
+ "--limit",
1040
+ ],
1041
+ });
1042
+ if (values.positionals.length > 1) {
1043
+ throw new Error("slack accepts one action: coverage or read.");
1044
+ }
1045
+ const rawAction = values.positionals[0] ?? "coverage";
1046
+ if (rawAction !== "coverage" && rawAction !== "read") {
1047
+ throw new Error("slack action must be coverage or read.");
1048
+ }
1049
+ const base = {
1050
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
1051
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1052
+ json: values.booleans.has("--json"),
1053
+ };
1054
+ if (rawAction === "coverage") {
1055
+ // `--workspace` here is a Slack workspace key, NOT the collection-root
1056
+ // `--workspace <path>` every setup command takes. Named at the point of use
1057
+ // so the two cannot be confused silently.
1058
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
1059
+ if (workspace && !SLACK_WORKSPACE_KEYS.includes(workspace)) {
1060
+ throw new Error(`slack --workspace must be one of ${SLACK_WORKSPACE_KEYS.join(", ")}; got ${workspace}.`);
1061
+ }
1062
+ return {
1063
+ kind: "slack",
1064
+ action: "coverage",
1065
+ workspace,
1066
+ staleOnly: values.booleans.has("--stale-only"),
1067
+ ...base,
1068
+ };
1069
+ }
1070
+ const person = optionalNonEmpty(values.flags.get("--person"));
1071
+ const channel = optionalNonEmpty(values.flags.get("--channel"));
1072
+ const query = optionalNonEmpty(values.flags.get("--query"));
1073
+ if (!person && !channel && !query) {
1074
+ throw new Error("slack read needs at least one of --person, --channel or --query. An unfiltered dump of every " +
1075
+ "message is not something this command offers.");
1076
+ }
1077
+ return {
1078
+ kind: "slack",
1079
+ action: "read",
1080
+ person,
1081
+ channel,
1082
+ query,
1083
+ since: optionalNonEmpty(values.flags.get("--since")),
1084
+ until: optionalNonEmpty(values.flags.get("--until")),
1085
+ limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
1086
+ ...base,
1087
+ };
1088
+ }
948
1089
  /** The narrowest width worth wrapping to; below it every line is one word. */
949
1090
  export const WORKBOOK_MIN_WIDTH = 20;
950
1091
  /**
@@ -1004,24 +1145,27 @@ function parseBriefArgs(args) {
1004
1145
  "--dashboard-url",
1005
1146
  "--for",
1006
1147
  "--as",
1148
+ "--who",
1007
1149
  "--version",
1008
1150
  "--tldr",
1009
1151
  "--full",
1010
1152
  "--versions",
1011
1153
  "--claims",
1154
+ "--render",
1012
1155
  "--reason",
1013
1156
  "--wait",
1014
1157
  "--no-wait",
1015
1158
  "--json",
1016
1159
  ],
1017
- valueFlags: ["--home", "--dashboard-url", "--for", "--as", "--version", "--reason"],
1160
+ valueFlags: ["--home", "--dashboard-url", "--for", "--as", "--who", "--version", "--reason"],
1018
1161
  });
1019
1162
  // Bare `cockpit brief` reads the page, which is what somebody typing it almost
1020
1163
  // always wants — the same shape `cockpit notes` and `cockpit scout` have.
1164
+ // `status` (BLI-3462) answers why a brief was or was not delivered.
1021
1165
  const first = values.positionals[0];
1022
1166
  const action = (first === undefined ? "read" : first);
1023
- if (!["read", "edit", "rewrite"].includes(action)) {
1024
- throw new Error(`Unknown brief command: ${first}. Try edit or rewrite, or nothing to read it.`);
1167
+ if (!["read", "edit", "rewrite", "status"].includes(action)) {
1168
+ throw new Error(`Unknown brief command: ${first}. Try edit, rewrite or status, or nothing to read it.`);
1025
1169
  }
1026
1170
  if (values.positionals.length > (first === undefined ? 0 : 1)) {
1027
1171
  throw new Error(`brief ${action} does not take "${values.positionals[1]}".`);
@@ -1050,17 +1194,26 @@ function parseBriefArgs(args) {
1050
1194
  }
1051
1195
  // `--as` is accepted as an alias so the two conversational commands read the
1052
1196
  // same way; `cockpit jarvis --as <person>` has meant this since BLI-3380.
1197
+ // `--who` is the third spelling, and it exists because `cockpit brief status
1198
+ // --who <person>` is the terminal echo of `jarvis:dm --who`.
1053
1199
  const forPerson = optionalNonEmpty(values.flags.get("--for"));
1054
1200
  const asPerson = optionalNonEmpty(values.flags.get("--as"));
1055
- if (forPerson && asPerson && forPerson !== asPerson) {
1056
- throw new Error("brief --for and --as must name the same person.");
1201
+ const whoPerson = optionalNonEmpty(values.flags.get("--who"));
1202
+ const named = [forPerson, asPerson, whoPerson].filter(Boolean);
1203
+ if (new Set(named).size > 1) {
1204
+ throw new Error("brief --for, --as and --who must name the same person.");
1205
+ }
1206
+ const render = values.booleans.has("--render");
1207
+ if (render && action !== "status") {
1208
+ throw new Error("brief --render belongs to `cockpit brief status`; the page is printed by default.");
1057
1209
  }
1058
1210
  return {
1059
1211
  kind: "brief",
1060
1212
  action,
1213
+ render,
1061
1214
  homeDir: optionalNonEmpty(values.flags.get("--home")),
1062
1215
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1063
- subject: forPerson ?? asPerson,
1216
+ subject: forPerson ?? asPerson ?? whoPerson,
1064
1217
  version: optionalNonEmpty(values.flags.get("--version")),
1065
1218
  tldr,
1066
1219
  versions: values.booleans.has("--versions"),
@@ -24,6 +24,8 @@ export const rootCommandNames = new Set([
24
24
  "jarvis",
25
25
  "model",
26
26
  "scout",
27
+ "ops",
28
+ "slack",
27
29
  "settings",
28
30
  "team",
29
31
  "workbook",
@@ -57,10 +59,13 @@ export function localCommandHelp(command) {
57
59
  " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
58
60
  " cockpit model [show|set <provider:model>] [--json]",
59
61
  " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
62
+ " cockpit ops [status [--job <id>] [--skips] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
63
+ " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [--person <p>] [--channel <c>] [--query <text>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>]] [--json]",
60
64
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
61
65
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes]] [--json]",
62
66
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
63
67
  " cockpit brief [edit|rewrite] [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
68
+ " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
64
69
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
65
70
  " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
66
71
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
@@ -305,6 +310,52 @@ function localSubcommandHelp(command) {
305
310
  "Run `cockpit login` first if this machine is not paired.",
306
311
  ],
307
312
  ],
313
+ [
314
+ "ops",
315
+ [
316
+ "Usage: cockpit ops [status [--job <id>] [--skips] | recompile --person <email|name|id> [--dry-run]] [--json]",
317
+ "",
318
+ " cockpit ops status",
319
+ " One line per scheduled job: when it last produced something, and whether that",
320
+ " is late FOR THAT JOB. A once-daily job quiet for 18 hours reads ok; a",
321
+ " quarter-hourly one does not. Every line names the schedule it is judged against.",
322
+ " A job that is not ok also prints what its artifact does and does not prove —",
323
+ " several only write a row when there is something new, so quiet can mean a quiet",
324
+ " week rather than a broken cron.",
325
+ " --job <id> asks about one; --skips adds the open Slack and external-sync skips,",
326
+ " grouped by reason. Exits 1 if anything is stale, empty or unreadable.",
327
+ " cockpit ops recompile --person <email|name|id> [--dry-run]",
328
+ " Writes that person's page again, now. Your own page is always yours to",
329
+ " recompile; somebody else's is for the people who read across the team.",
330
+ " --dry-run resolves the person and says which page is being served, compiling",
331
+ " nothing. A compile only ever ADDS a version — nothing is overwritten.",
332
+ " It can take minutes, and if Tower's 800-second budget runs out first you are",
333
+ " told exactly that: the compile may still have finished. Check with",
334
+ " `cockpit brief --for <person>`.",
335
+ " There is no --cadence: the compile path behind this writes a daily, and a flag",
336
+ " that was accepted and dropped would be worse than no flag.",
337
+ ],
338
+ ],
339
+ [
340
+ "slack",
341
+ [
342
+ "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [filters]] [--json]",
343
+ "",
344
+ " cockpit slack coverage",
345
+ " What Cockpit's Slack bot can see: how many channels it is in, what each",
346
+ " visibility state means, which cursors have not moved, and which ingest skips",
347
+ " are open. No message text at all, so anybody signed in can run it.",
348
+ " This is the command that tells an empty search apart from a coverage gap.",
349
+ " --stale-only drops the per-state breakdown and shows just what is behind.",
350
+ " cockpit slack read --person <p> | --channel <c> | --query <text>",
351
+ " The messages themselves, through the same reader JARVIS uses. At least one",
352
+ " filter is required. --since/--until are YYYY-MM-DD; --limit caps the rows.",
353
+ " Reading Slack reads across the team, so this is for the people who do that",
354
+ " job; being refused it never costs you `cockpit slack coverage`.",
355
+ " An empty or partial answer prints Tower's own sentence about WHY, word for",
356
+ " word — none of those reasons means nothing was said.",
357
+ ],
358
+ ],
308
359
  [
309
360
  "workbook",
310
361
  [
@@ -335,6 +386,15 @@ function localSubcommandHelp(command) {
335
386
  "--claims prints the [claimId] beside every line, which is what `cockpit correct --claim` takes.",
336
387
  "Reading it here counts as opening it, exactly as opening it in a browser does.",
337
388
  "",
389
+ " cockpit brief status [--who <person>] [--render]",
390
+ " Why your brief did or did not arrive this morning — one reason, from the same",
391
+ " chain the Slack DM cron itself follows: not_due, not_monday, not_the_1st,",
392
+ " no_page, page_stale, no_slack_identity, engagement_write_failed, delivered.",
393
+ " It shows your delivery window, which cadence owns this morning, whether a",
394
+ " Slack identity is on record, and the delivered/opened/clicked receipts.",
395
+ " --render adds the TLDR the DM would have carried.",
396
+ " --who <person> asks about somebody else and is for the people who read across",
397
+ " the team. Exits 0 only on `delivered`.",
338
398
  "edit — opens the page in $VISUAL/$EDITOR as a document: one [claimId] line per sentence you may rewrite, everything else a # comment. Change the words after an id, save, close. Only the lines that really changed are sent, so an accidental save writes nothing.",
339
399
  " An id you delete or invent is refused by name BEFORE anything is sent: a missing line reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart.",
340
400
  " --reason \"<why>\" rides on every row, exactly like a commit message. Optional; the before-and-after already teaches on its own.",
@@ -19,6 +19,10 @@
19
19
  * jarvis.ts terminal adapter for the shared JARVIS gateway
20
20
  * scout.ts `cockpit scout` — the board, and the three card verbs
21
21
  * (+ scout-render.ts, the pure layout half)
22
+ * ops.ts `cockpit ops` — the pipeline board and one recompile
23
+ * (+ ops-render.ts, the pure layout half, shared with
24
+ * `cockpit brief status`)
25
+ * slack.ts `cockpit slack` — bot coverage, and the messages
22
26
  * workbook.ts `cockpit workbook` — the project document library
23
27
  * (+ workbook-render.ts, the pure layout half)
24
28
  * brief.ts `cockpit brief` — the TODAY page in the terminal
@@ -50,6 +54,8 @@ import { runStatus } from "./status.js";
50
54
  import { runSessions } from "./sessions.js";
51
55
  import { runJarvis } from "./jarvis.js";
52
56
  import { runScout } from "./scout.js";
57
+ import { runOps } from "./ops.js";
58
+ import { runSlack } from "./slack.js";
53
59
  import { runModel, runSettings } from "./settings.js";
54
60
  import { runTeam } from "./team.js";
55
61
  import { asRecord, callTower, openTower } from "./tower-command.js";
@@ -131,6 +137,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
131
137
  return await runModel(command, io);
132
138
  case "scout":
133
139
  return await runScout(command, io);
140
+ case "ops":
141
+ return await runOps(command, io);
142
+ case "slack":
143
+ return await runSlack(command, io);
134
144
  case "settings":
135
145
  return await runSettings(command, io);
136
146
  case "team":
@@ -0,0 +1,150 @@
1
+ /**
2
+ * How the pipeline board reads in a terminal (BLI-3462). Pure layout: no io, no
3
+ * network, no clock of its own.
4
+ *
5
+ * The one rule this file exists to keep is the one the server keeps too: an age
6
+ * is never printed without the schedule it should be judged against. "40h ago"
7
+ * beside a once-daily job means something different from "40h ago" beside a
8
+ * quarter-hourly one, and a board that prints the first number without the
9
+ * second is how BLI-3276 became an outage report about a job that was fine.
10
+ *
11
+ * A verdict that is not `ok` also prints the job's own caveat — what its
12
+ * artifact does and does not prove — because that is exactly the moment
13
+ * somebody is about to conclude something from it.
14
+ */
15
+ /** The word a person reads. Short, fixed width, and never a bare colour. */
16
+ export function verdictWord(verdict) {
17
+ switch (verdict) {
18
+ case "healthy":
19
+ return "ok";
20
+ case "stale":
21
+ return "STALE";
22
+ case "never_produced":
23
+ return "EMPTY";
24
+ case "unreadable":
25
+ return "UNREAD";
26
+ case "no_artifact":
27
+ return "n/a";
28
+ default:
29
+ return verdict ?? "?";
30
+ }
31
+ }
32
+ /** `13h`, `2d 4h`, or `—` when nothing was ever written. */
33
+ export function ageWord(ageHours) {
34
+ if (ageHours == null)
35
+ return "—";
36
+ if (ageHours < 1)
37
+ return `${Math.max(0, Math.round(ageHours * 60))}m`;
38
+ if (ageHours < 48)
39
+ return `${Math.round(ageHours)}h`;
40
+ const days = Math.floor(ageHours / 24);
41
+ return `${days}d ${Math.round(ageHours - days * 24)}h`;
42
+ }
43
+ /** "at most every 13h", the sentence an age has to be read against. */
44
+ export function intervalWord(row) {
45
+ const hours = row.expectedIntervalHours;
46
+ if (hours == null)
47
+ return "schedule unknown";
48
+ if (hours < 1)
49
+ return `every ${Math.round(hours * 60)}m`;
50
+ if (hours >= 168)
51
+ return "weekly";
52
+ if (hours >= 24)
53
+ return `every ${Math.round(hours / 24)}d`;
54
+ return `every ${Math.round(hours)}h`;
55
+ }
56
+ export function renderOpsStatus(payload, dim) {
57
+ const rows = payload.pipelines ?? [];
58
+ const lines = [];
59
+ const counts = new Map();
60
+ for (const row of rows)
61
+ counts.set(row.verdict ?? "?", (counts.get(row.verdict ?? "?") ?? 0) + 1);
62
+ const summary = [...counts.entries()]
63
+ .sort((left, right) => right[1] - left[1])
64
+ .map(([verdict, count]) => `${count} ${verdictWord(verdict)}`)
65
+ .join(" · ");
66
+ lines.push(`PIPELINES ${rows.length} job${rows.length === 1 ? "" : "s"}${summary ? ` · ${summary}` : ""}`);
67
+ lines.push("");
68
+ const idWidth = Math.max(4, ...rows.map((row) => (row.id ?? "").length));
69
+ for (const row of rows) {
70
+ const verdict = verdictWord(row.verdict).padEnd(6);
71
+ const id = (row.id ?? "?").padEnd(idWidth);
72
+ const age = ageWord(row.ageHours).padStart(7);
73
+ lines.push(`${verdict} ${id} ${age} ${dim(intervalWord(row))}`);
74
+ if (row.verdict !== "healthy") {
75
+ if (row.detail)
76
+ lines.push(dim(` ${row.detail}`));
77
+ if (row.caveat)
78
+ lines.push(dim(` note: ${row.caveat}`));
79
+ if (row.configFile)
80
+ lines.push(dim(` scheduled by ${row.configFile} (${row.cron ?? "?"})`));
81
+ }
82
+ }
83
+ const slack = payload.skips?.slack;
84
+ const external = payload.skips?.external;
85
+ if (slack || external) {
86
+ lines.push("");
87
+ lines.push("OPEN SKIPS");
88
+ for (const ledger of [slack, external]) {
89
+ if (!ledger)
90
+ continue;
91
+ lines.push(...renderSkipLedger(ledger, dim));
92
+ }
93
+ }
94
+ return lines;
95
+ }
96
+ function renderSkipLedger(ledger, dim) {
97
+ const lines = [];
98
+ const name = ledger.relation ?? "skips";
99
+ if (ledger.error) {
100
+ lines.push(` ${name}: could not be read (${ledger.error})`);
101
+ return lines;
102
+ }
103
+ const groups = ledger.groups ?? [];
104
+ if (groups.length === 0) {
105
+ // "none" is an answer only when it is a read that succeeded, which it is.
106
+ lines.push(` ${name}: none ${dim(`(${ledger.openMeans ?? "open"})`)}`);
107
+ return lines;
108
+ }
109
+ const total = ledger.total ?? groups.reduce((sum, group) => sum + (group.count ?? 0), 0);
110
+ lines.push(` ${name}: ${ledger.truncated ? `at least ${total}` : total} ${dim(`(${ledger.openMeans ?? "open"})`)}`);
111
+ for (const group of groups) {
112
+ lines.push(` ${String(group.count ?? 0).padStart(5)} ${group.reason ?? "unlabelled"}`);
113
+ }
114
+ return lines;
115
+ }
116
+ export function renderBriefStatus(payload, dim) {
117
+ const status = payload.status ?? {};
118
+ const lines = [];
119
+ lines.push(`${status.displayName ?? "This person"} — ${status.reason ?? "unknown"}`);
120
+ if (status.detail)
121
+ lines.push(status.detail);
122
+ lines.push("");
123
+ lines.push(dim(`window ${status.windowInstant ?? "?"} (${status.deliveryLocalTime ?? "?"} ${status.timeZone ?? "?"}), ` +
124
+ `${Math.round(status.hoursSinceWindow ?? 0)}h ago · ${status.owningCadence ?? "?"} owns this morning`));
125
+ if (status.page) {
126
+ lines.push(dim(`page ${status.page.pageId ?? "(no id)"} · ${status.page.cadence ?? "?"} · compiled ${status.page.compiledAt ?? "?"}`));
127
+ if (status.page.note && status.page.note !== status.page.cadence) {
128
+ lines.push(dim(` ${status.page.note}`));
129
+ }
130
+ }
131
+ lines.push(dim(`slack ${status.slack?.workspace ?? "bli"}: ${status.slack?.hasIdentity ? "identity on record" : "no identity on record"}`));
132
+ for (const kind of ["delivered", "opened", "clicked"]) {
133
+ const receipt = status.receipts?.[kind];
134
+ lines.push(dim(`${kind.padEnd(10)} ${receipt?.occurredAt ?? "—"}`));
135
+ }
136
+ if (status.readError) {
137
+ lines.push(dim(`read error: ${status.readError} — the reason above is unreliable.`));
138
+ }
139
+ if (payload.render) {
140
+ lines.push("");
141
+ if (payload.render.reason === "ok" && payload.render.text) {
142
+ lines.push(payload.render.text);
143
+ }
144
+ else {
145
+ // Never an empty gap where the brief should be.
146
+ lines.push(dim(`(no text to show: ${payload.render.reason ?? "unknown"})`));
147
+ }
148
+ }
149
+ return lines;
150
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `cockpit ops` — the pipeline board, and forcing one page to be written again,
3
+ * from a terminal (BLI-3462).
4
+ *
5
+ * `ops status` prints one row per scheduled job with the newest artifact it
6
+ * wrote and a verdict computed against THAT JOB'S own expected interval. The
7
+ * intervals, the verdicts and the caveats all come from the server
8
+ * (`lib/ops/pipeline-status.ts`); this file prints them and decides an exit
9
+ * code. A terminal that judged staleness with its own copy of the schedule
10
+ * would eventually disagree with the board, and the one that is wrong is always
11
+ * the one somebody is reading.
12
+ *
13
+ * **Exit codes are the whole point of running this in a script.** Zero when
14
+ * every job that CAN be answered for is healthy; 1 when anything is stale,
15
+ * empty or unreadable. `no_artifact` — a job that writes no row — does not fail
16
+ * the command, and it does not pass silently either: it prints as `n/a` with
17
+ * the reason there is nothing to read.
18
+ *
19
+ * `ops recompile` spends a real provider call, so it says what it is about to
20
+ * do and reports the ceiling by name. A run that outlives Tower's own 800-second
21
+ * budget produces no response at all, which is indistinguishable from a network
22
+ * failure by status code alone — so it is reported as
23
+ * `recompile_ceiling_exceeded`, with the effect stated as UNKNOWN rather than
24
+ * failed: the compile may well have finished after the connection was cut.
25
+ */
26
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
27
+ import { renderOpsStatus } from "./ops-render.js";
28
+ import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
29
+ /**
30
+ * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
31
+ * seconds; waiting slightly past it is what lets this command tell "Tower gave
32
+ * up" from "the network did", instead of hanging up first and blaming the
33
+ * wrong one.
34
+ */
35
+ const RECOMPILE_DEADLINE_MS = 830_000;
36
+ const TOWER_RECOMPILE_CEILING_SECONDS = 800;
37
+ export async function runOps(command, io) {
38
+ const tower = await openTower("ops", command, io);
39
+ return command.action === "status"
40
+ ? runOpsStatus(command, io, tower)
41
+ : runOpsRecompile(command, io, tower);
42
+ }
43
+ async function runOpsStatus(command, io, tower) {
44
+ const params = new URLSearchParams();
45
+ if (command.job)
46
+ params.set("job", command.job);
47
+ if (command.skips)
48
+ params.set("skips", "1");
49
+ const query = params.toString();
50
+ const result = await callTower(tower, {
51
+ path: `/api/ops/status${query ? `?${query}` : ""}`,
52
+ label: "ops-status",
53
+ });
54
+ if (!result.ok) {
55
+ writeLine(io.stderr, `[ops cli] status not read ${JSON.stringify({
56
+ reason: result.reason,
57
+ http_status: result.httpStatus ?? null,
58
+ })}`);
59
+ return writeCommandFailure(io, command.json, result);
60
+ }
61
+ const payload = asRecord(result.body);
62
+ const rows = payload.pipelines ?? [];
63
+ const unhealthy = rows.filter((row) => row.verdict === "stale" || row.verdict === "never_produced" || row.verdict === "unreadable");
64
+ if (command.json) {
65
+ writeLine(io.stdout, JSON.stringify(payload));
66
+ }
67
+ else {
68
+ const styled = colorEnabled(io);
69
+ for (const line of renderOpsStatus(payload, (text) => dim(text, styled))) {
70
+ writeLine(io.stdout, line);
71
+ }
72
+ }
73
+ // Both branches log — an all-green board that says nothing cannot answer
74
+ // "did anybody look today?".
75
+ writeLine(io.stderr, `[ops cli] status read ${JSON.stringify({
76
+ job: command.job ?? "all",
77
+ pipelines: rows.length,
78
+ unhealthy: unhealthy.length,
79
+ unhealthy_ids: unhealthy.map((row) => row.id ?? "?"),
80
+ with_skips: Boolean(command.skips),
81
+ })}`);
82
+ return unhealthy.length > 0 ? 1 : 0;
83
+ }
84
+ async function runOpsRecompile(command, io, tower) {
85
+ const person = command.person ?? "";
86
+ if (!command.json) {
87
+ writeLine(io.stderr, command.dryRun
88
+ ? `Checking who "${person}" is and what is being served for them. Nothing will be compiled.`
89
+ : `Writing ${person}'s page again. This spends a model call and can take several minutes.`);
90
+ }
91
+ const result = await callTower(tower, {
92
+ path: "/api/ops/recompile",
93
+ method: "POST",
94
+ body: { person, dryRun: command.dryRun === true },
95
+ label: "ops-recompile",
96
+ timeoutMs: RECOMPILE_DEADLINE_MS,
97
+ });
98
+ if (!result.ok) {
99
+ // The ceiling is its own outcome, and its effect is UNKNOWN, not failed.
100
+ const ceiling = result.reason === "turn_timed_out";
101
+ const failure = ceiling
102
+ ? {
103
+ reason: "recompile_ceiling_exceeded",
104
+ detail: `Tower's ${TOWER_RECOMPILE_CEILING_SECONDS}-second budget for one recompile ran out before it ` +
105
+ "answered. That is not the same as the compile failing: it may have finished after the " +
106
+ "connection was cut. Run `cockpit brief --for <person>` to see which page is being served now.",
107
+ ...(result.httpStatus === undefined ? {} : { httpStatus: result.httpStatus }),
108
+ }
109
+ : result;
110
+ writeLine(io.stderr, `[ops cli] recompile failed ${JSON.stringify({
111
+ reason: failure.reason,
112
+ http_status: result.httpStatus ?? null,
113
+ dry_run: command.dryRun === true,
114
+ })}`);
115
+ return writeCommandFailure(io, command.json, failure);
116
+ }
117
+ const body = asRecord(result.body);
118
+ const status = typeof body["status"] === "string" ? body["status"] : "compiled";
119
+ const displayName = typeof body["displayName"] === "string" ? body["displayName"] : person;
120
+ const pageId = typeof body["pageId"] === "string" ? body["pageId"] : null;
121
+ const isNowLive = body["isNowLive"] === true;
122
+ if (command.json) {
123
+ writeLine(io.stdout, JSON.stringify(body));
124
+ }
125
+ else if (status === "dry_run") {
126
+ const latest = typeof body["latestPageId"] === "string" ? body["latestPageId"] : null;
127
+ writeLine(io.stdout, `${displayName} is on the roster.`);
128
+ writeLine(io.stdout, latest
129
+ ? `The page being served for them right now is ${latest}.`
130
+ : "Nothing has ever been written for them.");
131
+ writeLine(io.stdout, "Nothing was compiled — this was a dry run.");
132
+ }
133
+ else {
134
+ writeLine(io.stdout, `Wrote ${displayName}'s page again: ${pageId ?? "(no id returned)"}`);
135
+ writeLine(io.stdout, isNowLive
136
+ ? "It is the page being served now."
137
+ : "It is NOT the page being served: a scheduled compile is stamped for a later delivery window, " +
138
+ "so that one still wins. Nothing was lost — this is a new version alongside it.");
139
+ }
140
+ writeLine(io.stderr, `[ops cli] recompile done ${JSON.stringify({
141
+ status,
142
+ page_id: pageId,
143
+ is_now_live: status === "compiled" ? isNowLive : null,
144
+ dry_run: command.dryRun === true,
145
+ })}`);
146
+ return 0;
147
+ }
@@ -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.42");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.44");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `cockpit slack` — what Cockpit's Slack bot can see, and what it collected
3
+ * (BLI-3462).
4
+ *
5
+ * Two subcommands with two different audiences, and the split is the point:
6
+ *
7
+ * - `coverage` is collection health — channel names, how many the bot is in,
8
+ * which cursors are stale, which skips are open. No message text at all, so
9
+ * any signed-in person can run it. An intern who cannot tell "nobody said
10
+ * that" from "the bot is not in that channel" files a bug we already know
11
+ * about.
12
+ * - `read` relays what people actually said, including in private channels the
13
+ * bot sits in, so the server gates it on the same principal check JARVIS's
14
+ * own `readSlack` tool answers to. A refusal here is the system working, and
15
+ * it says which of the two commands is open to everyone.
16
+ *
17
+ * **The server's `summary` and `note` are printed verbatim.** They are the
18
+ * sentences that keep an empty result honest — `stale_ingest`,
19
+ * `bot_not_in_channel`, `channel_never_ingested` all look identical to "nothing
20
+ * was said" unless named, and a terminal that reworded them would eventually be
21
+ * the surface that got it wrong.
22
+ */
23
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
24
+ import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
25
+ export async function runSlack(command, io) {
26
+ const tower = await openTower("slack", command, io);
27
+ return command.action === "coverage"
28
+ ? runCoverageAction(command, io, tower)
29
+ : runReadAction(command, io, tower);
30
+ }
31
+ async function runCoverageAction(command, io, tower) {
32
+ const query = command.workspace ? `?workspace=${encodeURIComponent(command.workspace)}` : "";
33
+ const result = await callTower(tower, {
34
+ path: `/api/ops/slack/coverage${query}`,
35
+ label: "slack-coverage",
36
+ });
37
+ if (!result.ok) {
38
+ writeLine(io.stderr, `[slack cli] coverage not read ${JSON.stringify({
39
+ reason: result.reason,
40
+ http_status: result.httpStatus ?? null,
41
+ })}`);
42
+ return writeCommandFailure(io, command.json, result);
43
+ }
44
+ const body = asRecord(result.body);
45
+ const coverage = asRecord(body["coverage"]);
46
+ const workspaces = Array.isArray(coverage["workspaces"])
47
+ ? coverage["workspaces"]
48
+ : [];
49
+ if (command.json) {
50
+ writeLine(io.stdout, JSON.stringify(body));
51
+ }
52
+ else {
53
+ const styled = colorEnabled(io);
54
+ for (const line of coverageLines(coverage, workspaces, command.staleOnly === true, (text) => dim(text, styled))) {
55
+ writeLine(io.stdout, line);
56
+ }
57
+ }
58
+ const staleTotal = workspaces.reduce((sum, row) => sum + numberOf(row["staleChannelsTotal"]), 0);
59
+ writeLine(io.stderr, `[slack cli] coverage read ${JSON.stringify({
60
+ workspace: command.workspace ?? "all",
61
+ workspaces: workspaces.length,
62
+ covered: workspaces.reduce((sum, row) => sum + numberOf(row["covered"]), 0),
63
+ stale: staleTotal,
64
+ open_skips: workspaces.reduce((sum, row) => sum + numberOf(row["openSkipsTotal"]), 0),
65
+ stale_only: command.staleOnly === true,
66
+ })}`);
67
+ // A stale cursor is a finding, not a failure of this command: exit 0 so a
68
+ // person reading coverage is not told their own read broke.
69
+ return 0;
70
+ }
71
+ function coverageLines(coverage, workspaces, staleOnly, paint) {
72
+ const lines = [];
73
+ const staleAfter = numberOf(coverage["staleAfterHours"]);
74
+ for (const workspace of workspaces) {
75
+ const key = String(workspace["workspace"] ?? "?");
76
+ const known = numberOf(workspace["channelsKnown"]);
77
+ const covered = numberOf(workspace["covered"]);
78
+ lines.push(`${key.toUpperCase()} ${covered} of ${known} channels readable`);
79
+ if (!staleOnly) {
80
+ const byVisibility = Array.isArray(workspace["byVisibility"])
81
+ ? workspace["byVisibility"]
82
+ : [];
83
+ for (const entry of byVisibility) {
84
+ lines.push(paint(` ${String(numberOf(entry["count"])).padStart(4)} ${String(entry["state"] ?? "?")} — ${String(entry["meaning"] ?? "?")}`));
85
+ }
86
+ lines.push(paint(` newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`));
87
+ }
88
+ const stale = Array.isArray(workspace["staleChannels"])
89
+ ? workspace["staleChannels"]
90
+ : [];
91
+ const staleTotal = numberOf(workspace["staleChannelsTotal"]);
92
+ if (staleTotal === 0) {
93
+ lines.push(paint(` no channel is over ${staleAfter}h since its last sync`));
94
+ }
95
+ else {
96
+ lines.push(` ${staleTotal} channel${staleTotal === 1 ? "" : "s"} over ${staleAfter}h since last sync:`);
97
+ for (const entry of stale) {
98
+ lines.push(paint(` ${String(entry["name"] ?? "?")} last ${String(entry["lastSyncedAt"] ?? "never")}`));
99
+ }
100
+ if (stale.length < staleTotal)
101
+ lines.push(paint(` …and ${staleTotal - stale.length} more`));
102
+ }
103
+ const skips = Array.isArray(workspace["openSkips"])
104
+ ? workspace["openSkips"]
105
+ : [];
106
+ if (skips.length > 0) {
107
+ lines.push(` open ingest skips:`);
108
+ for (const skip of skips) {
109
+ lines.push(paint(` ${String(numberOf(skip["count"])).padStart(4)} ${String(skip["reason"] ?? "?")}`));
110
+ }
111
+ }
112
+ lines.push("");
113
+ }
114
+ const orphanChannels = numberOf(coverage["orphanChannels"]);
115
+ if (orphanChannels > 0) {
116
+ lines.push(paint(`${orphanChannels} channel(s) belong to no known workspace and are not counted above.`));
117
+ }
118
+ if (typeof coverage["note"] === "string")
119
+ lines.push(paint(coverage["note"]));
120
+ return lines;
121
+ }
122
+ async function runReadAction(command, io, tower) {
123
+ const body = {};
124
+ if (command.person)
125
+ body["person"] = command.person;
126
+ if (command.channel)
127
+ body["channel"] = command.channel;
128
+ if (command.query)
129
+ body["query"] = command.query;
130
+ if (command.since)
131
+ body["since"] = command.since;
132
+ if (command.until)
133
+ body["until"] = command.until;
134
+ if (command.limit !== undefined)
135
+ body["limit"] = command.limit;
136
+ const result = await callTower(tower, {
137
+ path: "/api/ops/slack/read",
138
+ method: "POST",
139
+ body,
140
+ label: "slack-read",
141
+ });
142
+ if (!result.ok) {
143
+ writeLine(io.stderr, `[slack cli] read refused ${JSON.stringify({
144
+ reason: result.reason,
145
+ http_status: result.httpStatus ?? null,
146
+ })}`);
147
+ if (!command.json && result.httpStatus === 403) {
148
+ writeLine(io.stderr, result.detail);
149
+ writeLine(io.stderr, "`cockpit slack coverage` is open to everyone and answers what the bot can see.");
150
+ return 1;
151
+ }
152
+ return writeCommandFailure(io, command.json, result);
153
+ }
154
+ const payload = asRecord(result.body);
155
+ const read = asRecord(payload["result"]);
156
+ const messages = Array.isArray(read["messages"])
157
+ ? read["messages"]
158
+ : [];
159
+ if (command.json) {
160
+ writeLine(io.stdout, JSON.stringify(payload));
161
+ }
162
+ else {
163
+ const styled = colorEnabled(io);
164
+ // The server's own summary, first and verbatim: it is the sentence that
165
+ // keeps an empty answer from reading as "nobody said anything".
166
+ if (typeof read["summary"] === "string")
167
+ writeLine(io.stdout, read["summary"]);
168
+ if (messages.length > 0)
169
+ writeLine(io.stdout, "");
170
+ for (const message of messages) {
171
+ writeLine(io.stdout, `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`);
172
+ writeLine(io.stdout, ` ${String(message["text"] ?? "")}`);
173
+ if (typeof message["permalink"] === "string") {
174
+ writeLine(io.stdout, dim(` ${message["permalink"]}`, styled));
175
+ }
176
+ }
177
+ if (typeof read["note"] === "string" && read["note"]) {
178
+ writeLine(io.stdout, "");
179
+ writeLine(io.stdout, dim(read["note"], styled));
180
+ }
181
+ }
182
+ const coverage = asRecord(read["coverage"]);
183
+ writeLine(io.stderr, `[slack cli] read ${JSON.stringify({
184
+ status: read["status"] ?? null,
185
+ reason: read["reason"] ?? null,
186
+ returned: messages.length,
187
+ channels_searched: numberOf(coverage["channelsSearched"]),
188
+ stale_channels: numberOf(coverage["staleChannelsTotal"]),
189
+ // Presence only — a search term is the person's own words.
190
+ has_person: Boolean(command.person),
191
+ has_channel: Boolean(command.channel),
192
+ has_query: Boolean(command.query),
193
+ })}`);
194
+ return 0;
195
+ }
196
+ function numberOf(value) {
197
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
198
+ }
@@ -19,7 +19,12 @@
19
19
  */
20
20
  import { readPipedText, writeLine } from "./cli-io.js";
21
21
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
- /** Client-side ceiling. These routes are small reads and writes, not turns. */
22
+ /**
23
+ * Client-side ceiling. These routes are small reads and writes, not turns —
24
+ * except `/api/ops/recompile`, which runs a whole compile and passes its own
25
+ * `timeoutMs` (BLI-3462). A caller that needs longer says so; nothing here
26
+ * silently waits forever.
27
+ */
23
28
  const REQUEST_DEADLINE_MS = 30_000;
24
29
  export async function openTower(commandName, command, io) {
25
30
  const session = await loadPairedSession(commandName, command.homeDir);
@@ -38,7 +43,7 @@ export async function callTower(context, options) {
38
43
  fetch: context.fetch,
39
44
  method: options.method ?? "GET",
40
45
  ...(options.body === undefined ? {} : { body: options.body }),
41
- timeoutMs: REQUEST_DEADLINE_MS,
46
+ timeoutMs: options.timeoutMs ?? REQUEST_DEADLINE_MS,
42
47
  label: options.label,
43
48
  log: context.log,
44
49
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {