@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.
- package/dist/adapters/raw-evidence-collection-state.js +52 -6
- package/dist/adapters/raw-evidence-completeness.js +6 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +2 -2
- package/dist/adapters/raw-evidence-image-reader.js +5 -2
- package/dist/adapters/raw-evidence-transcript-file.js +2 -2
- package/dist/autostart-opt-out.js +43 -0
- package/dist/autostart-self-heal.js +23 -10
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +6 -4
- package/dist/commands/backfill-upload.js +12 -0
- package/dist/commands/doctor-access.js +17 -21
- package/dist/commands/doctor-disk-words.js +20 -0
- package/dist/commands/doctor-mcp.js +27 -0
- package/dist/commands/doctor-pipeline.js +34 -7
- package/dist/commands/doctor-registration.js +9 -3
- package/dist/commands/doctor-report.js +28 -0
- package/dist/commands/doctor.js +20 -5
- package/dist/commands/heartbeat-token.js +14 -0
- package/dist/commands/heartbeat.js +2 -1
- package/dist/commands/local-args-tower-logs.js +53 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-command-shapes-logs.js +6 -0
- package/dist/commands/local-help-commands-tower-ops.js +26 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/logs.js +68 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups-staging.js +8 -1
- package/dist/commands/sync-heal.js +144 -0
- package/dist/commands/sync.js +9 -1
- package/dist/disk-prune.js +35 -3
- package/dist/disk-retention.js +31 -1
- package/dist/sign-in-action.js +7 -0
- package/dist/staging-gate-receipt.js +131 -0
- package/dist/staging-gate.js +214 -0
- package/dist/state-dir-audit.js +5 -4
- package/dist/state-dir-prune.js +84 -0
- package/dist/sync-tick-stamp.js +22 -0
- 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`
|
|
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", "
|
|
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`
|
|
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",
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
}
|
|
@@ -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.",
|
package/dist/commands/local.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|