@bli-cockpit/cli 0.2.168 → 0.2.173

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.
Files changed (40) hide show
  1. package/dist/adapters/raw-evidence-collection-state.js +52 -6
  2. package/dist/adapters/raw-evidence-completeness.js +6 -0
  3. package/dist/adapters/raw-evidence-git-diff-reader.js +2 -2
  4. package/dist/adapters/raw-evidence-image-reader.js +5 -2
  5. package/dist/adapters/raw-evidence-transcript-file.js +2 -2
  6. package/dist/autostart-opt-out.js +43 -0
  7. package/dist/autostart-self-heal.js +23 -10
  8. package/dist/commands/autostart-command.js +10 -0
  9. package/dist/commands/autostart-heal.js +6 -4
  10. package/dist/commands/backfill-upload.js +12 -0
  11. package/dist/commands/doctor-access.js +17 -21
  12. package/dist/commands/doctor-disk-words.js +20 -0
  13. package/dist/commands/doctor-mcp.js +27 -0
  14. package/dist/commands/doctor-pipeline.js +34 -7
  15. package/dist/commands/doctor-registration.js +9 -3
  16. package/dist/commands/doctor-report.js +28 -0
  17. package/dist/commands/doctor.js +20 -5
  18. package/dist/commands/heartbeat-token.js +14 -0
  19. package/dist/commands/heartbeat.js +2 -1
  20. package/dist/commands/local-args-tower-logs.js +53 -0
  21. package/dist/commands/local-args-tower.js +1 -0
  22. package/dist/commands/local-args.js +3 -1
  23. package/dist/commands/local-command-shapes-logs.js +6 -0
  24. package/dist/commands/local-help-commands-tower-ops.js +26 -0
  25. package/dist/commands/local-help.js +2 -0
  26. package/dist/commands/local.js +3 -0
  27. package/dist/commands/logs.js +68 -0
  28. package/dist/commands/public-root.js +1 -1
  29. package/dist/commands/sync-followups-staging.js +8 -1
  30. package/dist/commands/sync-heal.js +144 -0
  31. package/dist/commands/sync.js +9 -1
  32. package/dist/disk-prune.js +35 -3
  33. package/dist/disk-retention.js +31 -1
  34. package/dist/sign-in-action.js +7 -0
  35. package/dist/staging-gate-receipt.js +131 -0
  36. package/dist/staging-gate.js +214 -0
  37. package/dist/state-dir-audit.js +5 -4
  38. package/dist/state-dir-prune.js +84 -0
  39. package/dist/sync-tick-stamp.js +22 -0
  40. package/package.json +4 -4
@@ -1,6 +1,8 @@
1
1
  import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
2
2
  import { doctorRoots } from "./doctor-access.js";
3
3
  import { installAutostartAgent } from "../autostart.js";
4
+ import { clearAutostartOptOut } from "../autostart-opt-out.js";
5
+ import { getCollectorRuntimePaths } from "../local-state.js";
4
6
  import { fail, needsFix, ok, skipped } from "./doctor-report.js";
5
7
  import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
6
8
  import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
@@ -41,7 +43,7 @@ export async function fixAutostartState(context) {
41
43
  // leave the row red forever (BLI-3793).
42
44
  const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
43
45
  if (roots.length === 0)
44
- return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` to choose approved collection roots first.");
46
+ return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
45
47
  const result = await installAutostartAgent({
46
48
  ...(roots[0] ? { repoRoot: roots[0] } : {}),
47
49
  repoRoots: roots,
@@ -52,6 +54,10 @@ export async function fixAutostartState(context) {
52
54
  if (result.status === "unsupported") {
53
55
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
54
56
  }
57
+ if (result.loaded !== false) {
58
+ // BLI-12828: a person asked for background sync again; the tick may keep it.
59
+ await clearAutostartOptOut(getCollectorRuntimePaths(context.command.homeDir));
60
+ }
55
61
  if (result.loaded === false) {
56
62
  return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
57
63
  }
@@ -111,12 +117,12 @@ function memoryStepState(outcome, phase) {
111
117
  }
112
118
  export async function checkDoctorAgentRules(context) {
113
119
  const result = await inspectAgentRules({ homeDir: context.command.homeDir, scopePaths: await doctorRoots(context) });
114
- return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "run `cockpit agent-rules install`");
120
+ return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "agent rules are missing; `cockpit agent-rules install` writes them");
115
121
  }
116
122
  export async function fixDoctorAgentRules(context) {
117
123
  const roots = await doctorRoots(context);
118
124
  if (roots.length === 0)
119
- return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` to choose approved roots first.");
125
+ return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
120
126
  await installAgentRules({ homeDir: context.command.homeDir, scopePaths: roots });
121
127
  return checkDoctorAgentRules(context);
122
128
  }
@@ -70,6 +70,8 @@ setupReceipt, repairs = []) {
70
70
  writeLine(io.stderr, `${row.id}:`);
71
71
  writeLine(io.stderr, row.message.replaceAll("—", ";"));
72
72
  }
73
+ for (const line of repairReceiptLines(repairs))
74
+ writeLine(io.stdout, line);
73
75
  // BLI-3731. The invariant table says whether collection is healthy; this
74
76
  // says whether the machine is CONNECTED — the browser sign-in, the device
75
77
  // session, both agent hosts and the scheduler, every word read back off this
@@ -132,6 +134,32 @@ export function asRecord(value) {
132
134
  export function writeLine(stream, text) {
133
135
  stream.write(`${text}\n`);
134
136
  }
137
+ /** Fills each repair's before -> after receipt from the diagnosis and the final rows. */
138
+ export function finalizeDoctorRepairs(repairs, diagnosed, rows, attempted) {
139
+ for (const repair of repairs) {
140
+ const found = diagnosed.get(repair.step);
141
+ const final = rows.find((row) => row.id === repair.step);
142
+ if (found) {
143
+ repair.before = `${found.status}:${found.code}`;
144
+ repair.before_message = oneLine(found.message);
145
+ }
146
+ if (final) {
147
+ repair.after = `${final.status}:${final.code}`;
148
+ repair.after_message = oneLine(final.message);
149
+ }
150
+ repair.fix_attempted = attempted.has(repair.step);
151
+ }
152
+ }
153
+ /** The "Repairs:" block of the human table; empty when no fix ran. */
154
+ export function repairReceiptLines(repairs) {
155
+ const ran = repairs.filter((repair) => repair.fix_attempted);
156
+ if (ran.length === 0)
157
+ return [];
158
+ return [
159
+ "Repairs (before -> after):",
160
+ ...ran.map((repair) => ` ${repair.after?.startsWith("ok:") ? "fixed" : "still needs you"} ${repair.step}: ${repair.before_message ?? ""} -> ${repair.after_message ?? ""}`.replaceAll("—", ";")),
161
+ ];
162
+ }
135
163
  export function doctorNeedsPerson(rows, receipt) {
136
164
  const defaults = {
137
165
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
@@ -4,11 +4,11 @@ import { describeError } from "../health-detail.js";
4
4
  import { withStage } from "../crash-guard.js";
5
5
  import { checkSingleInstallState, readRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
6
6
  import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
7
- import { checkMcpAnswersState } from "./doctor-mcp.js";
7
+ import { checkMcpAnswersState, fixMcpAnswersState } from "./doctor-mcp.js";
8
8
  import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
9
9
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
10
10
  import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
11
- import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
11
+ import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, finalizeDoctorRepairs, } from "./doctor-report.js";
12
12
  import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
13
13
  import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
14
14
  export async function runDoctor(command, io, hooks, overrides = {}) {
@@ -24,6 +24,7 @@ export async function runDoctorWithDeps(command, io, deps) {
24
24
  const repairs = [];
25
25
  const diagnoseOnly = command.dryRun || command.checkOnly;
26
26
  const diagnosed = new Map();
27
+ const attempted = new Set();
27
28
  // BLI-11101: every row says how long it took, so the next slow run names
28
29
  // the row without a profiler. A killed run still leaves every finished row.
29
30
  const checkMs = new Map();
@@ -54,6 +55,7 @@ export async function runDoctorWithDeps(command, io, deps) {
54
55
  repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
55
56
  }
56
57
  else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
58
+ attempted.add(invariant.id);
57
59
  row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
58
60
  repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
59
61
  // The replacement process owns the one final receipt, including JSON.
@@ -106,6 +108,8 @@ export async function runDoctorWithDeps(command, io, deps) {
106
108
  }
107
109
  }
108
110
  }
111
+ // BLI-12828: every repair's receipt says what it found and what it left.
112
+ finalizeDoctorRepairs(repairs, diagnosed, rows, attempted);
109
113
  const floor = await maybeReportDoctorEvents(context, rows);
110
114
  if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
111
115
  const index = rows.findIndex((row) => row.id === "cli-latest");
@@ -127,6 +131,15 @@ export async function runDoctorWithDeps(command, io, deps) {
127
131
  writeDoctorOutput(command, io, rows, receipt, repairs);
128
132
  return needsPerson === 0 ? 0 : 1;
129
133
  }
134
+ /**
135
+ * The table's row ids in order, and whether each one carries a repair.
136
+ * `doctor-repair-classes.test.ts` pins this against the runbook's audit table
137
+ * (`docs/runbooks/cockpit-doctor-repairs.md`) so a row cannot be added without
138
+ * either a repair or a named reason it has none.
139
+ */
140
+ export function doctorInvariantTable() {
141
+ return doctorInvariants().map((invariant) => ({ id: invariant.id, hasFix: Boolean(invariant.fix) }));
142
+ }
130
143
  function doctorInvariants() {
131
144
  return [
132
145
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
@@ -166,12 +179,14 @@ function doctorInvariants() {
166
179
  // BLI-3804. Right after the registration row, because it asks the second
167
180
  // half of the same question: `memory-registered` proves the entry exists
168
181
  // and names a bin, this one proves the server behind it starts, speaks the
169
- // protocol and reaches Tower. Deliberately has NO fix — re-registering a
170
- // server that is already registered repairs nothing, and the row names the
171
- // command a person should run instead (`doctor-mcp.ts`).
182
+ // protocol and reaches Tower. BLI-12828 gave it the repair the row always
183
+ // named in words: re-register, take the newest CLI, ask again. A Tower
184
+ // refusal is the sign-in row's, so that outcome is returned untouched
185
+ // (`doctor-mcp.ts`).
172
186
  {
173
187
  id: "mcp-answers",
174
188
  check: (context) => context.deps.checkMcpAnswers(context),
189
+ fix: fixMcpAnswersState,
175
190
  },
176
191
  // BLI-3884. Beside the two rows above because it is the same family — what
177
192
  // this machine's agents can reach — and, like `mcp-answers`, it has NO fix:
@@ -77,4 +77,18 @@ async function readTokenExpiry(response) {
77
77
  catch {
78
78
  return null;
79
79
  }
80
+ }
81
+ /**
82
+ * BLI-12828. The one fact the tick's sign-in heal needs from the check-in it
83
+ * just made: did the DOOR refuse this machine's token (401/403), as opposed to
84
+ * the network being down or the build being a checkout? A lapsed local date
85
+ * alone proves nothing offline. Process-scoped on purpose: one tick is one
86
+ * process, and `null` means "no refusal observed", never "accepted".
87
+ */
88
+ let lastRefusalStatus = null;
89
+ export function noteHeartbeatRefusal(status) {
90
+ lastRefusalStatus = status;
91
+ }
92
+ export function heartbeatRefusalStatus() {
93
+ return lastRefusalStatus;
80
94
  }
@@ -29,7 +29,7 @@ import { readStagingInventory } from "../disk-usage.js";
29
29
  import { describeError } from "../health-detail.js";
30
30
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
31
31
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
32
- import { readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
32
+ import { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
33
33
  import { classifySyncHealthError } from "../sync-health-class.js";
34
34
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
35
35
  import { readMemoryHookCounts } from "./memory-hook-counts.js";
@@ -284,6 +284,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
284
284
  body: JSON.stringify(heartbeat),
285
285
  signal: controller.signal,
286
286
  });
287
+ noteHeartbeatRefusal(response.ok ? null : response.status);
287
288
  if (!response.ok) {
288
289
  // A heartbeat is not retried, so the reason has to be said once, here.
289
290
  // BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `cockpit logs` argument parsing (BLI-12833).
3
+ *
4
+ * cockpit logs <source> [--since 15m|1h|2d|<iso>] [--raw <template-id>] [--limit <n>]
5
+ * [--all-levels] [--table <file>] [--json]
6
+ *
7
+ * <source> is `collector`, `dashboard`, `railway`, `railway:<service>` or `-`
8
+ * (lines on stdin). Read-only: the digest is computed on this machine from the
9
+ * source's own CLI or files; nothing is sent to Tower and no dashboard is
10
+ * called, so there is no `--dashboard-url`.
11
+ */
12
+ import { optionalNonEmpty, optionalPositiveInteger, parseNamedArgs } from "./local-arg-values.js";
13
+ /** The source spellings, for the usage line and the refusal. `railway:<service>` takes any service name. */
14
+ export const LOGS_SOURCES = ["collector", "dashboard", "railway", "railway:<service>", "-"];
15
+ const SOURCE_PATTERN = /^(collector|dashboard|railway|railway:[A-Za-z0-9][A-Za-z0-9._-]{0,63}|-)$/;
16
+ const SINCE_PATTERN = /^(\d{1,4}[smhd]|\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
17
+ export function parseLogsArgs(args) {
18
+ const values = parseNamedArgs(args, {
19
+ allowedFlags: ["--home", "--since", "--raw", "--limit", "--table", "--all-levels", "--json"],
20
+ valueFlags: ["--home", "--since", "--raw", "--limit", "--table"],
21
+ });
22
+ const source = values.positionals[0];
23
+ if (!source || !SOURCE_PATTERN.test(source)) {
24
+ throw new Error(`logs needs a source: ${LOGS_SOURCES.join(", ")}. Example: \`cockpit logs dashboard --since 1h\`.`);
25
+ }
26
+ if (values.positionals.length > 1)
27
+ throw new Error("logs takes one source; put the window in --since.");
28
+ const since = optionalNonEmpty(values.flags.get("--since"));
29
+ if (since && !SINCE_PATTERN.test(since)) {
30
+ throw new Error(`--since wants 15m, 1h, 2d or an ISO time like 2026-10-07T03:00Z; got ${JSON.stringify(since.slice(0, 40))}.`);
31
+ }
32
+ const raw = optionalNonEmpty(values.flags.get("--raw"));
33
+ if (raw && !/^[0-9a-f]{8}$/.test(raw)) {
34
+ throw new Error("--raw wants the 8 hex characters a digest line starts with.");
35
+ }
36
+ const rawLimit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
37
+ if (rawLimit !== undefined && !raw)
38
+ throw new Error("--limit counts the original lines --raw prints.");
39
+ if (values.booleans.has("--all-levels") && source !== "dashboard") {
40
+ throw new Error("--all-levels belongs to `cockpit logs dashboard`.");
41
+ }
42
+ return {
43
+ kind: "logs",
44
+ source,
45
+ ...(since ? { since } : {}),
46
+ ...(raw ? { raw } : {}),
47
+ ...(rawLimit === undefined ? {} : { rawLimit }),
48
+ ...(values.flags.get("--table") ? { tableFile: values.flags.get("--table") } : {}),
49
+ allLevels: values.booleans.has("--all-levels"),
50
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
51
+ json: values.booleans.has("--json"),
52
+ };
53
+ }
@@ -38,5 +38,6 @@ export { parseModelsArgs } from "./local-args-tower-models.js";
38
38
  export { parseMailArgs } from "./local-args-tower-mail.js";
39
39
  export { parseCalArgs } from "./local-args-tower-cal.js";
40
40
  export { parseUsageArgs } from "./local-args-tower-usage.js";
41
+ export { parseLogsArgs } from "./local-args-tower-logs.js";
41
42
  export { parseCareersArgs } from './local-args-tower-careers.js';
42
43
  export { parseRenameArgs } from './local-args-tower-rename.js';
@@ -18,7 +18,7 @@
18
18
  import { parseRootsArgs } from "./roots.js";
19
19
  import { SURFACE_ALIASES } from "./local-help.js";
20
20
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
21
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
21
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseLogsArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
22
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
23
23
  // `local-auth.ts` import it from here — so it stays exported from this address
24
24
  // even though it now lives next door. The same goes for the four names the
@@ -133,6 +133,8 @@ export function parseLocalArgs(argv) {
133
133
  return parseRenameArgs(argv.slice(1));
134
134
  case "usage":
135
135
  return parseUsageArgs(argv.slice(1));
136
+ case "logs":
137
+ return parseLogsArgs(argv.slice(1));
136
138
  default:
137
139
  throw new Error(`Unknown local command: ${command ?? ""}`);
138
140
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `cockpit logs` command shape (BLI-12833), beside `local-command-shapes.ts` for
3
+ * the same reason `local-command-shapes-github.ts` is: that file sits at the
4
+ * readability ceiling.
5
+ */
6
+ export {};
@@ -10,6 +10,32 @@
10
10
  * something breaks, so it is a user-visible contract like any other.
11
11
  */
12
12
  export const TOWER_OPS_COMMAND_HELP = [
13
+ [
14
+ "logs",
15
+ [
16
+ "Usage: cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
17
+ "",
18
+ "A token-cheap digest of Tower's own logs, read on THIS machine (nothing is uploaded). Variable parts of",
19
+ "each line are masked into a template, equal templates are counted, routine runs become counts, and every",
20
+ "template is classified: our_bug and unknown first, then capacity, config_drift, rate_pushback, hiccup,",
21
+ "and one COUNTED line for dead_source and benign (counted, never alerted). About 2 KB.",
22
+ " collector this machine's sync.log, sync.err.log and their rotated archives (hook logs go to sync.err.log).",
23
+ " These logs have no per-line time, so the window is approximate (the digest says so).",
24
+ " dashboard Vercel runtime logs of bli-cockpit-dashboard (team bli-inc) through the `vercel` CLI.",
25
+ " Reads what Vercel marks error, warning or fatal plus every 5xx; --all-levels reads everything (slow).",
26
+ " railway every service of the bli-cockpit-inference project through the `railway` CLI;",
27
+ " railway:<service> one service (ladder, runner, linear-sync, ...). Project and environment are always passed",
28
+ " explicitly: this repo's `railway link` points at the runner service.",
29
+ " - lines on stdin: plain text, `journalctl -o json`, `railway logs --json` or `vercel logs --json`.",
30
+ "--raw <template-id> the original lines behind one template id from a digest (redacted, newest last; --limit n, default 20).",
31
+ "--table <file> a class table JSON (same shape as the packaged one) to try a new pattern before committing it.",
32
+ "A source that cannot be read is a named refusal, never a stack trace: vercel_cli_missing, vercel_not_logged_in,",
33
+ "railway_cli_missing, railway_not_logged_in, railway_service_unknown, collector_logs_missing, stdin_is_terminal, ...",
34
+ "--json writes one object to stdout: { ok, kind, source, window, lines, templates[], routine, notes, text, measure };",
35
+ "a refusal is { ok: false, error, detail } and exits 1. Run list and teaching: docs/runbooks/tower-logs.md.",
36
+ "The bli-tower MCP server has the twin: logs_digest.",
37
+ ],
38
+ ],
13
39
  [
14
40
  "github",
15
41
  [
@@ -78,6 +78,7 @@ export const rootCommandNames = new Set([
78
78
  "careers",
79
79
  "rename",
80
80
  "usage",
81
+ "logs",
81
82
  ...SURFACE_ALIASES.keys(),
82
83
  ]);
83
84
  /** Public names omit compatibility spellings while the router keeps accepting them. */
@@ -132,6 +133,7 @@ export function localCommandHelp(command) {
132
133
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
133
134
  " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
134
135
  " cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
136
+ " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
135
137
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
136
138
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
137
139
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -49,6 +49,7 @@ import { runSearch } from "./search.js";
49
49
  import { runCareers } from "./careers.js";
50
50
  import { runRename } from "./rename-scope.js";
51
51
  import { runUsage } from "./usage.js";
52
+ import { runLogs } from "./logs.js";
52
53
  import { parseLocalArgs } from "./local-args.js";
53
54
  // `./local.js` is the published entry point for this command surface: the
54
55
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -242,6 +243,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
242
243
  return await runRename(command, io);
243
244
  case "usage":
244
245
  return await runUsage(command, io);
246
+ case "logs":
247
+ return await runLogs(command, io);
245
248
  }
246
249
  }
247
250
  catch (error) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `cockpit logs <source>` — a token-cheap digest of Tower's own logs (BLI-12833).
3
+ *
4
+ * Read-only and local: the source's own CLI or files are read on THIS machine,
5
+ * masked into templates, counted and classified by `@bli-cockpit/telemetry-core/log-digest`,
6
+ * and printed in about 2 KB with real bugs (`our_bug`, `unknown`) first. Nothing
7
+ * is uploaded and no Tower door is called. The MCP server's `logs_digest` tool
8
+ * calls the same `digestSource`, so the two print the same thing.
9
+ *
10
+ * A source that cannot be read is a NAMED refusal (`vercel_cli_missing`,
11
+ * `railway_not_logged_in`, ...): one line on stderr, `{ ok: false, error,
12
+ * detail }` on stdout under `--json`, exit 1. Never a stack trace.
13
+ */
14
+ import { readFile } from "node:fs/promises";
15
+ import { ClassTable, digestSource } from "@bli-cockpit/telemetry-core/log-digest";
16
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
17
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
18
+ const TAG = "[logs cli]";
19
+ const MAX_STDIN_CHARS = 64 * 1024 * 1024;
20
+ function refuse(io, json, reason, detail) {
21
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
22
+ if (json)
23
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
24
+ else
25
+ writeLine(io.stderr, detail);
26
+ return 1;
27
+ }
28
+ export async function runLogs(command, io, deps = {}) {
29
+ const paths = getCollectorRuntimePaths(command.homeDir);
30
+ let table;
31
+ if (command.tableFile) {
32
+ try {
33
+ table = ClassTable.fromJson(await readFile(command.tableFile, "utf8"));
34
+ }
35
+ catch (error) {
36
+ return refuse(io, command.json, "table_unreadable", `--table ${command.tableFile} is not a readable class table: ${error instanceof Error ? error.message.slice(0, 160) : "unknown error"}`);
37
+ }
38
+ }
39
+ let stdinText;
40
+ const stdinIsTerminal = command.source === "-" && isInteractiveStdin(io);
41
+ if (command.source === "-" && !stdinIsTerminal) {
42
+ try {
43
+ stdinText = await readPipedText(io.stdin, { maxChars: MAX_STDIN_CHARS, overflowMessage: "logs - reads at most 64 MB of piped lines." });
44
+ }
45
+ catch (error) {
46
+ return refuse(io, command.json, "stdin_too_large", error instanceof Error ? error.message : "stdin could not be read");
47
+ }
48
+ }
49
+ const outcome = await digestSource({
50
+ source: command.source,
51
+ since: command.since,
52
+ raw: command.raw,
53
+ rawLimit: command.rawLimit,
54
+ table,
55
+ allLevels: command.allLevels,
56
+ }, {
57
+ stateDir: paths.state_dir,
58
+ stdinText,
59
+ stdinIsTerminal,
60
+ run: deps.run,
61
+ nowMs: deps.nowMs,
62
+ log: (message, fields) => writeLine(io.stderr, `${message} ${JSON.stringify(fields)}`),
63
+ });
64
+ if (!outcome.ok)
65
+ return refuse(io, command.json, outcome.reason, outcome.detail);
66
+ writeLine(io.stdout, command.json ? JSON.stringify(outcome) : outcome.text);
67
+ return 0;
68
+ }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.168");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.173");
25
25
  return 0;
26
26
  }
27
27
 
@@ -1,6 +1,7 @@
1
1
  import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
2
2
  import { getCollectorRuntimePaths } from "../local-state.js";
3
- import { runStagingPrune } from "../disk-prune.js";
3
+ import { runStagingPrune, terminalReclaimDetail } from "../disk-prune.js";
4
+ import { runGateFollowUp } from "../staging-gate-receipt.js";
4
5
  import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
5
6
  import { runEvidenceRedelivery, } from "../evidence-redelivery.js";
6
7
  /** How many reconcile batches (of up to 500 hashes each) one sync tick may spend. */
@@ -67,6 +68,11 @@ export async function runStagingPruneAfterSync(command, io, dashboardUrl, option
67
68
  if (result.reason !== "nothing_to_prune") {
68
69
  events.push(stagingPruneEvent(result));
69
70
  }
71
+ // BLI-12832: bound the two disposable caches, then say what the staging gate
72
+ // is doing, once per change. Neither can fail the tick.
73
+ const gateEvent = await runGateFollowUp(getCollectorRuntimePaths(command.homeDir), io.env, options.now ?? new Date());
74
+ if (gateEvent)
75
+ events.push(gateEvent);
70
76
  if (events.length === 0)
71
77
  return;
72
78
  await reportInstallEventsBestEffort({
@@ -179,6 +185,7 @@ function stagingPruneEvent(result) {
179
185
  result.failed_deletions > 0
180
186
  ? `failed_deletions ${result.failed_deletions}`
181
187
  : null,
188
+ terminalReclaimDetail(result),
182
189
  ]
183
190
  .filter((part) => Boolean(part))
184
191
  .join("; ");
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The tick's machine-heal follow-ups that `cockpit doctor` also performs
3
+ * (BLI-12828). Doctor and the tick are meant to be the same repairs run by a
4
+ * person and run by the clock; `docs/runbooks/cockpit-doctor-repairs.md` is the
5
+ * table of which row each one answers.
6
+ *
7
+ * agent rules missing re-install them inspected every tick, a
8
+ * failed install retried hourly
9
+ * sign-in lapsed record the ONE human written when the heartbeat
10
+ * action, stop nagging could not renew, reported daily
11
+ *
12
+ * The rest of the table is already on the tick elsewhere: the CLI floor
13
+ * (`sync-followups-self-update.ts`), the scheduler incl. an ABSENT one
14
+ * (`sync-followups-autostart.ts` over `../autostart-self-heal.ts`), BLI Memory
15
+ * (`sync-followups-memory.ts`) and staging (`sync-followups-staging.ts`).
16
+ *
17
+ * Same rule as every follow-up: it runs after collection's outcome is decided,
18
+ * never throws, and its outcome is its own named receipt, never a sync failure.
19
+ * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
20
+ */
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
24
+ import { heartbeatRefusalStatus } from "./heartbeat-token.js";
25
+ import { SIGN_IN_ACTION } from "../sign-in-action.js";
26
+ import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
27
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
28
+ import { normalizeCollectionRoots } from "../root-normalization.js";
29
+ export const AGENT_RULES_REPAIR_MARKER = ".last-agent-rules-repair";
30
+ export const SESSION_NEEDS_LOGIN_MARKER = "session-needs-login.json";
31
+ const DAY_MS = 24 * 60 * 60 * 1000;
32
+ /** Only a RETRY of a failed install waits; the first install never does. */
33
+ const AGENT_RULES_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
34
+ export async function runMachineHealAfterSync(command, io, dashboardUrl, options = {}) {
35
+ const now = options.now ?? new Date();
36
+ const paths = getCollectorRuntimePaths(command.homeDir);
37
+ const events = [];
38
+ for (const heal of [
39
+ () => healAgentRules(command, paths, now),
40
+ () => noteSessionState(paths, heartbeatRefusalStatus(), now),
41
+ ]) {
42
+ try {
43
+ const event = await heal();
44
+ if (event)
45
+ events.push(event);
46
+ }
47
+ catch (error) {
48
+ console.error("[sync-heal] a machine-heal step threw and was dropped", JSON.stringify({ reason: "heal_step_threw", detail: redactedSyncErrorDetail(error) }));
49
+ }
50
+ }
51
+ if (events.length === 0)
52
+ return;
53
+ await reportInstallEventsBestEffort({
54
+ homeDir: command.homeDir,
55
+ dashboardUrl,
56
+ command: "sync",
57
+ events,
58
+ json: command.json,
59
+ io,
60
+ });
61
+ }
62
+ /** Agent rules: cheap to read, so read every tick; write only when missing. */
63
+ async function healAgentRules(command, paths, now) {
64
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
65
+ const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
66
+ if (roots.length === 0) {
67
+ console.error("[sync-heal] agent rules not checked; no saved roots to scope them to", JSON.stringify({ reason: "no_saved_roots" }));
68
+ return null;
69
+ }
70
+ const scope = { ...(command.homeDir ? { homeDir: command.homeDir } : {}), scopePaths: roots };
71
+ const marker = path.join(paths.state_dir, AGENT_RULES_REPAIR_MARKER);
72
+ const before = await inspectAgentRules(scope);
73
+ if (before.installed) {
74
+ // A marker from an earlier attempt is spent; the next removal is
75
+ // reinstalled on the next tick, not after a retry window.
76
+ await fs.rm(marker, { force: true }).catch(() => undefined);
77
+ return null;
78
+ }
79
+ const last = await fs.stat(marker).catch(() => null);
80
+ if (last && now.getTime() - last.mtimeMs < AGENT_RULES_RETRY_AFTER_FAILURE_MS) {
81
+ console.error("[sync-heal] agent rules still missing; the last install attempt was under an hour ago, retrying after that", JSON.stringify({ reason: "agent_rules_repair_throttled" }));
82
+ return null;
83
+ }
84
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
85
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
86
+ await installAgentRules(scope);
87
+ const after = await inspectAgentRules(scope);
88
+ console.error(after.installed
89
+ ? "[sync-heal] agent rules were missing and are installed"
90
+ : "[sync-heal] agent rules install ran but they still read missing", JSON.stringify({ reason: after.installed ? "agent_rules_installed" : "agent_rules_still_missing", before: "missing", after: after.installed ? "installed" : "missing" }));
91
+ return after.installed
92
+ ? { step: "agent_rules_install", status: "ok", error_detail: "before=missing; after=installed" }
93
+ : { step: "agent_rules_install", status: "fail", error_code: "agent_rules_still_missing", error_detail: "before=missing; after=missing" };
94
+ }
95
+ /**
96
+ * Sign-in: the heartbeat (which ran just before this) renews a lapsed token
97
+ * inside the server's grace window without a browser. If this machine is STILL
98
+ * signed out or the door refused the token (401/403), only a browser can fix
99
+ * it, so record the one action and say it once a day instead of every fifteen
100
+ * minutes. A lapsed local date with no refusal (offline, a checkout build)
101
+ * proves nothing, so it says nothing.
102
+ */
103
+ async function noteSessionState(paths, refusedStatus, now) {
104
+ const marker = path.join(paths.state_dir, SESSION_NEEDS_LOGIN_MARKER);
105
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
106
+ const state = session ? toSessionReference(session).session_state : "missing";
107
+ const noted = await readMarker(marker);
108
+ const refused = refusedStatus === 401 || refusedStatus === 403;
109
+ if (state === "valid" && !refused) {
110
+ if (!noted)
111
+ return null;
112
+ await fs.rm(marker, { force: true }).catch(() => undefined);
113
+ console.error("[sync-heal] sign-in is valid again; the needs-login note is cleared", JSON.stringify({ reason: "session_renewed", before: "needs_login", after: "valid" }));
114
+ return { step: "session_renewal", status: "ok", error_detail: "before=needs_login; after=valid" };
115
+ }
116
+ if (state !== "missing" && !refused)
117
+ return null;
118
+ const nowIso = now.toISOString();
119
+ if (noted && now.getTime() - Date.parse(noted.last_reported_at) < DAY_MS) {
120
+ return null;
121
+ }
122
+ const record = { since: noted?.since ?? nowIso, last_reported_at: nowIso, session_state: state, action: SIGN_IN_ACTION };
123
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
124
+ await fs.writeFile(marker, JSON.stringify(record)).catch(() => undefined);
125
+ console.error("[sync-heal] this machine is signed out and a browser sign-in is the only fix; saying so once a day", JSON.stringify({ reason: "browser_sign_in_required", session_state: state, next_action: SIGN_IN_ACTION }));
126
+ return {
127
+ step: "session_renewal",
128
+ status: "fail",
129
+ error_code: "browser_sign_in_required",
130
+ error_detail: `${state}; ${SIGN_IN_ACTION}`,
131
+ };
132
+ }
133
+ async function readMarker(file) {
134
+ try {
135
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
136
+ if (typeof parsed["since"] === "string" && typeof parsed["last_reported_at"] === "string") {
137
+ return { since: parsed["since"], last_reported_at: parsed["last_reported_at"] };
138
+ }
139
+ }
140
+ catch {
141
+ // No marker, or an unreadable one, reads as "never said".
142
+ }
143
+ return null;
144
+ }