@bli-cockpit/cli 0.2.43 → 0.2.45
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/brief.js +58 -0
- package/dist/commands/local-args.js +159 -6
- package/dist/commands/local-help.js +60 -0
- package/dist/commands/local.js +10 -0
- package/dist/commands/ops-render.js +150 -0
- package/dist/commands/ops.js +147 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/slack.js +198 -0
- package/dist/commands/tower-command.js +7 -2
- package/package.json +1 -1
package/dist/commands/brief.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1056
|
-
|
|
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.",
|
package/dist/commands/local.js
CHANGED
|
@@ -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.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.45");
|
|
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
|
-
/**
|
|
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
|
});
|