@bli-cockpit/cli 0.2.179 → 0.2.183
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/agent-setup.js +152 -0
- package/dist/commands/brain.js +35 -0
- package/dist/commands/doctor-update.js +4 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/glossary.js +17 -0
- package/dist/commands/heartbeat.js +5 -1
- package/dist/commands/local-args-tower-admin.js +6 -1
- package/dist/commands/local-args-tower-brain.js +38 -0
- package/dist/commands/local-args-tower-memory.js +15 -0
- package/dist/commands/local-args-tower-pages.js +16 -3
- package/dist/commands/local-args-tower-setup.js +10 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +8 -1
- package/dist/commands/local-command-shapes-brain.js +5 -0
- package/dist/commands/local-help-commands-tower-ops.js +17 -0
- package/dist/commands/local-help-commands-tower.js +12 -4
- package/dist/commands/local-help-commands.js +3 -0
- package/dist/commands/local-help.js +7 -1
- package/dist/commands/local.js +11 -5
- package/dist/commands/memory-store.js +23 -3
- package/dist/commands/memory-verb-list.js +4 -1
- package/dist/commands/memory-visibility.js +48 -0
- package/dist/commands/notes-accounts.js +102 -20
- package/dist/commands/notes-reads.js +1 -1
- package/dist/commands/notes-recordings.js +113 -0
- package/dist/commands/notes-wisprflow-login.js +219 -0
- package/dist/commands/notes.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup.js +39 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync-doctor-request.js +13 -0
- package/dist/commands/sync-doctor.js +145 -0
- package/dist/commands/sync.js +4 -3
- package/dist/commands/team.js +19 -0
- package/dist/local-state-status.js +5 -1
- package/dist/raw-evidence-stuck-summary.js +21 -1
- package/package.json +4 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit notes connect --provider wisprflow` — signing a person into Wispr
|
|
3
|
+
* Flow's MCP server from a terminal (BLI-12926).
|
|
4
|
+
*
|
|
5
|
+
* Wispr Flow has no API key for meetings. Its Notetaker is read through a
|
|
6
|
+
* remote MCP server behind OAuth (docs.wisprflow.ai, "Connect Claude, ChatGPT
|
|
7
|
+
* or another MCP client to Wispr Flow": "no API key or token is needed"). Its
|
|
8
|
+
* authorization server publishes, at
|
|
9
|
+
* `https://mcp-auth.wisprflow.com/.well-known/oauth-authorization-server`
|
|
10
|
+
* (read 2026-10-08), dynamic client registration, PKCE `S256`, a refresh-token
|
|
11
|
+
* grant and the RFC 8628 DEVICE grant. The device grant is the one a terminal
|
|
12
|
+
* can use: it prints an address and a short code, the person approves in their
|
|
13
|
+
* own browser (Google, Apple, Microsoft or company single sign-on; an
|
|
14
|
+
* email-and-password Wispr account cannot complete MCP authorization), and this
|
|
15
|
+
* file polls until Wispr hands back a refresh token.
|
|
16
|
+
*
|
|
17
|
+
* What leaves this machine: a client registration, then the device and token
|
|
18
|
+
* calls, all to Wispr's own authorization server. The refresh token goes into a
|
|
19
|
+
* bundle (`{ v, client_id, refresh_token }`) that `connect` posts to Tower's
|
|
20
|
+
* accounts door, which seals it. It is never printed, never written to a file
|
|
21
|
+
* and never put in a flag; `--json` shows the account, not the bundle.
|
|
22
|
+
*
|
|
23
|
+
* **No live call has been made from this code.** Every shape is the RFC's, and
|
|
24
|
+
* the first real sign-in is the proof. In particular it is NOT known whether
|
|
25
|
+
* Wispr's server accepts a dynamically registered client on the device grant
|
|
26
|
+
* (the document lists both, not that they combine); if it does not, the refusal
|
|
27
|
+
* below says what Wispr answered and `--key-stdin` accepts a bundle made by any
|
|
28
|
+
* other means.
|
|
29
|
+
*
|
|
30
|
+
* stdout carries nothing from here; every line is stderr commentary.
|
|
31
|
+
*/
|
|
32
|
+
import { writeLine } from "./cli-io.js";
|
|
33
|
+
export const WISPRFLOW_AUTH_ISSUER = "https://mcp-auth.wisprflow.com";
|
|
34
|
+
export const WISPRFLOW_MCP_RESOURCE = "https://api.wisprflow.ai/connect/mcp";
|
|
35
|
+
const SCOPE = "openid offline_access";
|
|
36
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
37
|
+
const REQUEST_DEADLINE_MS = 30_000;
|
|
38
|
+
/** RFC 8628: a client polls no faster than the server's interval, default 5 s. */
|
|
39
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
40
|
+
/** Never wait on a person longer than this, whatever the server says. */
|
|
41
|
+
const MAX_WAIT_SECONDS = 15 * 60;
|
|
42
|
+
const TAG = "[notes wisprflow signin]";
|
|
43
|
+
export async function signInToWisprflow(options) {
|
|
44
|
+
const { io } = options;
|
|
45
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
|
+
const now = options.now ?? Date.now;
|
|
47
|
+
const metadata = await readMetadata(io);
|
|
48
|
+
if (!metadata.ok)
|
|
49
|
+
return metadata;
|
|
50
|
+
// 1. Register a client. A public client: no secret, only an id.
|
|
51
|
+
const registered = await postJson(io, metadata.value.registration_endpoint, {
|
|
52
|
+
client_name: "Tower (BLI) notes sync",
|
|
53
|
+
grant_types: [DEVICE_GRANT, "refresh_token"],
|
|
54
|
+
response_types: [],
|
|
55
|
+
token_endpoint_auth_method: "none",
|
|
56
|
+
scope: SCOPE,
|
|
57
|
+
});
|
|
58
|
+
if (!registered.ok)
|
|
59
|
+
return explain(io, "registration", registered);
|
|
60
|
+
const clientId = stringField(registered.body, "client_id");
|
|
61
|
+
if (!clientId) {
|
|
62
|
+
return { ok: false, reason: "wisprflow_registration_unusable", detail: "Wispr Flow accepted the registration but sent no client id." };
|
|
63
|
+
}
|
|
64
|
+
// 2. Ask for a code.
|
|
65
|
+
const device = await postForm(io, metadata.value.device_authorization_endpoint, {
|
|
66
|
+
client_id: clientId,
|
|
67
|
+
scope: SCOPE,
|
|
68
|
+
resource: WISPRFLOW_MCP_RESOURCE,
|
|
69
|
+
});
|
|
70
|
+
if (!device.ok)
|
|
71
|
+
return explain(io, "device code", device);
|
|
72
|
+
const deviceCode = stringField(device.body, "device_code");
|
|
73
|
+
const userCode = stringField(device.body, "user_code");
|
|
74
|
+
const address = stringField(device.body, "verification_uri_complete") ?? stringField(device.body, "verification_uri");
|
|
75
|
+
if (!deviceCode || !userCode || !address) {
|
|
76
|
+
return { ok: false, reason: "wisprflow_device_unusable", detail: "Wispr Flow's device answer was missing its code or its address." };
|
|
77
|
+
}
|
|
78
|
+
const waitSeconds = Math.min(numberField(device.body, "expires_in") ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS);
|
|
79
|
+
let intervalMs = (numberField(device.body, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000;
|
|
80
|
+
writeLine(io.stderr, "Sign in to Wispr Flow to let Tower read your meeting notes:");
|
|
81
|
+
writeLine(io.stderr, ` 1. Open ${address}`);
|
|
82
|
+
writeLine(io.stderr, ` 2. If it asks, enter the code ${userCode}`);
|
|
83
|
+
writeLine(io.stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
|
|
84
|
+
writeLine(io.stderr, `Waiting up to ${Math.round(waitSeconds / 60)} minutes for you to approve...`);
|
|
85
|
+
log(io, "waiting", { interval_s: intervalMs / 1000, wait_s: waitSeconds });
|
|
86
|
+
// 3. Poll.
|
|
87
|
+
const deadline = now() + waitSeconds * 1000;
|
|
88
|
+
while (now() < deadline) {
|
|
89
|
+
await sleep(intervalMs);
|
|
90
|
+
const polled = await postForm(io, metadata.value.token_endpoint, {
|
|
91
|
+
grant_type: DEVICE_GRANT,
|
|
92
|
+
device_code: deviceCode,
|
|
93
|
+
client_id: clientId,
|
|
94
|
+
resource: WISPRFLOW_MCP_RESOURCE,
|
|
95
|
+
});
|
|
96
|
+
if (polled.ok) {
|
|
97
|
+
const refreshToken = stringField(polled.body, "refresh_token");
|
|
98
|
+
if (!refreshToken) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "wisprflow_no_offline_access",
|
|
102
|
+
detail: "Wispr Flow signed you in but did not hand back a refresh token, so Tower could not read your meetings"
|
|
103
|
+
+ " every half hour without you. Nothing was connected.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
log(io, "signed in", { client_registered: true, refresh_token_received: true });
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
bundle: JSON.stringify({ v: 1, client_id: clientId, refresh_token: refreshToken }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const code = stringField(polled.body, "error");
|
|
113
|
+
if (code === "authorization_pending")
|
|
114
|
+
continue;
|
|
115
|
+
if (code === "slow_down") {
|
|
116
|
+
intervalMs += 5000;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (code === "expired_token") {
|
|
120
|
+
return { ok: false, reason: "wisprflow_code_expired", detail: "The code expired before it was approved. Run the command again." };
|
|
121
|
+
}
|
|
122
|
+
if (code === "access_denied") {
|
|
123
|
+
return { ok: false, reason: "wisprflow_denied", detail: "Wispr Flow says the request was declined. Nothing was connected." };
|
|
124
|
+
}
|
|
125
|
+
return explain(io, "sign-in", polled);
|
|
126
|
+
}
|
|
127
|
+
return { ok: false, reason: "wisprflow_timed_out", detail: "Nobody approved the code in time. Run the command again." };
|
|
128
|
+
}
|
|
129
|
+
/** Metadata only: a step, a status and an OAuth error label. Never a code or a token. */
|
|
130
|
+
function log(io, message, fields) {
|
|
131
|
+
writeLine(io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
|
|
132
|
+
}
|
|
133
|
+
async function readMetadata(io) {
|
|
134
|
+
const got = await send(io, `${WISPRFLOW_AUTH_ISSUER}/.well-known/oauth-authorization-server`, { method: "GET" });
|
|
135
|
+
if (!got.ok)
|
|
136
|
+
return explain(io, "discovery", got);
|
|
137
|
+
const registration = stringField(got.body, "registration_endpoint");
|
|
138
|
+
const device = stringField(got.body, "device_authorization_endpoint");
|
|
139
|
+
const token = stringField(got.body, "token_endpoint");
|
|
140
|
+
if (!registration || !device || !token) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: "wisprflow_discovery_changed",
|
|
144
|
+
detail: "Wispr Flow's sign-in no longer advertises client registration and the device flow. Tower cannot sign you in from a terminal until it does.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Every endpoint must stay on Wispr's own host: a discovery document that
|
|
148
|
+
// points the token call somewhere else is not one to send a code to.
|
|
149
|
+
for (const endpoint of [registration, device, token]) {
|
|
150
|
+
if (!endpoint.startsWith(`${WISPRFLOW_AUTH_ISSUER}/`)) {
|
|
151
|
+
return { ok: false, reason: "wisprflow_discovery_changed", detail: "Wispr Flow's sign-in points at a host Tower does not recognise, so nothing was sent." };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, value: { registration_endpoint: registration, device_authorization_endpoint: device, token_endpoint: token } };
|
|
155
|
+
}
|
|
156
|
+
async function postJson(io, url, body) {
|
|
157
|
+
return send(io, url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async function postForm(io, url, body) {
|
|
164
|
+
return send(io, url, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
167
|
+
body: new URLSearchParams(body).toString(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function send(io, url, init) {
|
|
171
|
+
const call = io.fetch;
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const deadline = setTimeout(() => controller.abort(), REQUEST_DEADLINE_MS);
|
|
174
|
+
try {
|
|
175
|
+
const response = await call(url, { ...init, signal: controller.signal });
|
|
176
|
+
let body = null;
|
|
177
|
+
try {
|
|
178
|
+
body = await response.json();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
body = null;
|
|
182
|
+
}
|
|
183
|
+
return response.ok ? { ok: true, body } : { ok: false, status: response.status, body, network: false };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return { ok: false, status: 0, body: null, network: true };
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
clearTimeout(deadline);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** A refusal in the vendor's own words: its OAuth error label, never a body dump. */
|
|
193
|
+
function explain(io, step, failure) {
|
|
194
|
+
if (failure.network) {
|
|
195
|
+
return { ok: false, reason: "wisprflow_unreachable", detail: `Could not reach Wispr Flow's sign-in during ${step}. Check the connection and try again.` };
|
|
196
|
+
}
|
|
197
|
+
const label = stringField(failure.body, "error");
|
|
198
|
+
const words = stringField(failure.body, "error_description");
|
|
199
|
+
log(io, "refused", { step, http_status: failure.status, oauth_error: label ?? "none" });
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: `wisprflow_${step.replace(/\s+/g, "_")}_refused`,
|
|
203
|
+
detail: `Wispr Flow refused the ${step} step (HTTP ${failure.status}${label ? `, ${label}` : ""}).`
|
|
204
|
+
+ `${words ? ` It said: ${words.slice(0, 200)}` : ""}`
|
|
205
|
+
+ " Nothing was connected.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function stringField(body, name) {
|
|
209
|
+
if (typeof body !== "object" || body === null)
|
|
210
|
+
return null;
|
|
211
|
+
const value = body[name];
|
|
212
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
213
|
+
}
|
|
214
|
+
function numberField(body, name) {
|
|
215
|
+
if (typeof body !== "object" || body === null)
|
|
216
|
+
return null;
|
|
217
|
+
const value = body[name];
|
|
218
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
219
|
+
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -45,10 +45,12 @@ import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-
|
|
|
45
45
|
import { runFolderCommand } from "./notes-folders.js";
|
|
46
46
|
import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
|
|
47
47
|
import { showGranolaStatus } from "./notes-granola-status.js";
|
|
48
|
+
import { listRecordings, showRecording } from "./notes-recordings.js";
|
|
48
49
|
export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
49
50
|
export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
50
51
|
export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
|
|
51
52
|
export { showGranolaStatus } from "./notes-granola-status.js";
|
|
53
|
+
export { listRecordings, showRecording } from "./notes-recordings.js";
|
|
52
54
|
export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
|
|
53
55
|
export async function runNotes(command, io) {
|
|
54
56
|
const session = await loadPairedSession("notes", command.homeDir);
|
|
@@ -98,6 +100,10 @@ export async function runNotes(command, io) {
|
|
|
98
100
|
return syncNotetaker(command, door);
|
|
99
101
|
case "sync-status":
|
|
100
102
|
return syncStatusNotetakers(command, door);
|
|
103
|
+
case "recordings":
|
|
104
|
+
return listRecordings(command, door);
|
|
105
|
+
case "recording":
|
|
106
|
+
return showRecording(command, door);
|
|
101
107
|
case "webhook":
|
|
102
108
|
return armNotesWebhook(command, door);
|
|
103
109
|
case "granola-status":
|
|
@@ -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.183");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { writeLine } from "./cli-io.js";
|
|
3
|
+
export async function runSetup(command, io) {
|
|
4
|
+
const door = await openAgentDoor("setup", command, io);
|
|
5
|
+
const query = new URLSearchParams();
|
|
6
|
+
if (command.person)
|
|
7
|
+
query.set("person", command.person);
|
|
8
|
+
if (command.compare)
|
|
9
|
+
query.set("compare", command.compare);
|
|
10
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/setup?${query}`, label: "setup", timeoutMs: 30_000 });
|
|
11
|
+
if (!answer.ok)
|
|
12
|
+
return failAgentDoor(door, "[setup]", answer.reason, answer.detail);
|
|
13
|
+
if (door.json)
|
|
14
|
+
return emitAgentDoor(door, answer.body);
|
|
15
|
+
const body = answer.body;
|
|
16
|
+
const number = (value) => value === null ? "unknown" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
|
17
|
+
for (const person of body.people ?? []) {
|
|
18
|
+
writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR · ${person.yield === null ? "unknown yield" : `${number(person.yield * 100)}% session yield`}`);
|
|
19
|
+
if (!person.machines.length)
|
|
20
|
+
writeLine(io.stdout, " No setup received.");
|
|
21
|
+
for (const machine of person.machines) {
|
|
22
|
+
if (!machine.setup) {
|
|
23
|
+
writeLine(io.stdout, " Setup is unknown.");
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
writeLine(io.stdout, ` ${machine.setup.os} · TOWER CLI ${machine.setup.cli_version}${machine.stale ? " · stale setup" : ""}`);
|
|
27
|
+
for (const tool of ["claude", "codex"])
|
|
28
|
+
writeLine(io.stdout, ` ${tool}: ${machine.setup[tool].model ?? "unknown model"}, ${machine.setup[tool].effort ?? "unknown effort"}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const pair of body.comparison ?? []) {
|
|
32
|
+
writeLine(io.stdout, `${pair.first ?? "Unknown person"} / ${pair.second ?? "Unknown person"}`);
|
|
33
|
+
for (const difference of pair.differences)
|
|
34
|
+
writeLine(io.stdout, ` ${difference}`);
|
|
35
|
+
}
|
|
36
|
+
if (body.note)
|
|
37
|
+
writeLine(io.stdout, body.note);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -80,7 +80,7 @@ async function writeMultiRepoStatus(command, io, backfillCursor, setupReceipt, w
|
|
|
80
80
|
writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
|
|
81
81
|
writeLine(io.stdout, await statusDiskLine(command.homeDir));
|
|
82
82
|
for (const status of statuses) {
|
|
83
|
-
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
83
|
+
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${uploadsLine(status.upload_state)}`);
|
|
84
84
|
}
|
|
85
85
|
writeConnectedBlock(io, setupReceipt);
|
|
86
86
|
return 0;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Only a doctor request is accepted. Nothing from Tower is ever a command line. */
|
|
2
|
+
let requestId = null;
|
|
3
|
+
export function noteDoctorRequest(value) {
|
|
4
|
+
if (!value || typeof value !== "object")
|
|
5
|
+
return;
|
|
6
|
+
const request = value;
|
|
7
|
+
if (request.action === "doctor" && typeof request.request_id === "string" && /^[0-9a-f-]{36}$/u.test(request.request_id)) {
|
|
8
|
+
requestId = request.request_id;
|
|
9
|
+
console.error("[sync-doctor] request received", JSON.stringify({ reason: "doctor_requested", request_id: requestId }));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function requestedDoctorId() { return requestId; }
|
|
13
|
+
export function clearDoctorRequest() { requestId = null; }
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Full, non-interactive doctor after collection, with a durable receipt retry. */
|
|
2
|
+
import { shouldSuppressFleetReceipts } from "../dev-build.js";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Writable } from "node:stream";
|
|
6
|
+
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
7
|
+
import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "../local-state.js";
|
|
8
|
+
import { isAutostartOptedOut } from "../autostart-opt-out.js";
|
|
9
|
+
import { runAutostartSelfHealAfterSync } from "./sync-followups-autostart.js";
|
|
10
|
+
import { checkAutostartState } from "./doctor-registration.js";
|
|
11
|
+
import { heartbeatRefusalStatus } from "./heartbeat-token.js";
|
|
12
|
+
import { runDoctor } from "./doctor.js";
|
|
13
|
+
import { doctorNeedsPerson, sanitizeEventCode } from "./doctor-report.js";
|
|
14
|
+
import { runSelfUpdate } from "./install-update.js";
|
|
15
|
+
import { reportInstallEventsBestEffort } from "./install-receipts.js";
|
|
16
|
+
import { clearDoctorRequest, requestedDoctorId } from "./sync-doctor-request.js";
|
|
17
|
+
export async function runDoctorAfterSync(command, io, dashboardUrl, overrides = {}, facts) {
|
|
18
|
+
if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
|
|
19
|
+
console.error("[sync-doctor] checkout does not repair the fleet machine", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
23
|
+
// A separate namespace: holding this lock must not block doctor's backfill.
|
|
24
|
+
const lock = await acquireBackfillLock({ ...paths, cursors_dir: path.join(paths.state_dir, "doctor") }).catch(() => {
|
|
25
|
+
console.error("[sync-doctor] could not acquire its lock", JSON.stringify({ reason: "doctor_lock_failed" }));
|
|
26
|
+
return null;
|
|
27
|
+
});
|
|
28
|
+
if (!lock)
|
|
29
|
+
return;
|
|
30
|
+
if (!lock.acquired) {
|
|
31
|
+
console.error("[sync-doctor] another doctor is running", JSON.stringify({ reason: "doctor_running" }));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const outbox = path.join(paths.state_dir, "doctor-receipt.json");
|
|
35
|
+
try {
|
|
36
|
+
const pending = await fs.readFile(outbox, "utf8").then((text) => JSON.parse(text)).catch((error) => {
|
|
37
|
+
if (error.code === "ENOENT")
|
|
38
|
+
return null;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
if (pending) {
|
|
42
|
+
if (!await postReceipt(pending, command, io, dashboardUrl))
|
|
43
|
+
return;
|
|
44
|
+
await fs.rm(outbox, { force: true });
|
|
45
|
+
if (pending.request_id === requestedDoctorId())
|
|
46
|
+
clearDoctorRequest();
|
|
47
|
+
// The completed requested run is never repeated just to resend its receipt.
|
|
48
|
+
if (pending.request_id)
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const requestId = requestedDoctorId();
|
|
52
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
53
|
+
let receipt = null;
|
|
54
|
+
const quiet = new Writable({ write(_chunk, _encoding, done) { done(); } });
|
|
55
|
+
const doctorIo = { ...io, stdout: quiet };
|
|
56
|
+
await runDoctor({ kind: "doctor", alias: "doctor", automaticTick: true, homeDir: command.homeDir, dashboardUrl, json: true, dryRun: false }, doctorIo, {
|
|
57
|
+
selfUpdate: (updateIo, options) => runSelfUpdate({ ...updateIo, stdout: quiet }, options),
|
|
58
|
+
runLogin: async () => { throw new Error("automatic_doctor_cannot_prompt"); },
|
|
59
|
+
resolveAndSaveRoots: async () => { throw new Error("automatic_doctor_cannot_pick_folders"); },
|
|
60
|
+
reportInstallEvents: (options) => reportInstallEventsBestEffort({ ...options, homeDir: command.homeDir,
|
|
61
|
+
// Automatic receipts carry labels, never terminal instructions or paths.
|
|
62
|
+
events: options.events.map((event) => ({ step: event.step, status: event.status, ...(event.error_code ? { error_code: event.error_code } : {}) })),
|
|
63
|
+
}),
|
|
64
|
+
onReceipt: (rows, repairs, setup) => {
|
|
65
|
+
const actions = new Set();
|
|
66
|
+
for (const item of doctorNeedsPerson(rows, setup)) {
|
|
67
|
+
if (item.step === "authed" && (!session?.device_token || [401, 403].includes(heartbeatRefusalStatus() ?? 0)))
|
|
68
|
+
actions.add("browser_sign_in");
|
|
69
|
+
if (item.step === "roots-ok")
|
|
70
|
+
actions.add("folder_selection");
|
|
71
|
+
if (item.step === "single-install")
|
|
72
|
+
actions.add("duplicate_install");
|
|
73
|
+
if (item.reason === "needs_trust" || item.action?.includes("/hooks"))
|
|
74
|
+
actions.add("hook_trust");
|
|
75
|
+
if (/permission|eacces|eperm|access_denied/u.test(item.reason))
|
|
76
|
+
actions.add("os_permission");
|
|
77
|
+
}
|
|
78
|
+
receipt = {
|
|
79
|
+
request_id: requestId, completed_at: new Date().toISOString(),
|
|
80
|
+
status: rows.some((row) => row.status === "fail" || row.status === "needs_fix") || actions.size ? "attention" : "pass",
|
|
81
|
+
repairs: repairs.map((repair) => ({ step: repair.step, before: safeVerdict(repair.before), after: safeVerdict(repair.after), fix_attempted: repair.fix_attempted ?? false })),
|
|
82
|
+
person_actions: [...actions],
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
}, {
|
|
86
|
+
// The collection phase already ran. Never recurse through another sync.
|
|
87
|
+
checkSync: async () => ({ id: "sync-fresh", status: facts?.status === "fail" ? "needs_fix" : "skipped", code: facts?.status === "fail" ? "tick_collection_failed" : "tick_collection_reported", message: "This tick already reported its collection outcome; it retries on the next tick." }),
|
|
88
|
+
fixSync: async (context) => context.deps.checkSync(context),
|
|
89
|
+
checkAutostart: async (context) => await isAutostartOptedOut(paths)
|
|
90
|
+
? { id: "autostart-alive", status: "skipped", code: "operator_uninstalled", message: "Background sync was deliberately turned off." }
|
|
91
|
+
: checkAutostartState(context),
|
|
92
|
+
fixAutostart: async (context) => {
|
|
93
|
+
// macOS uses the detached healer; Windows repairs in place. Neither
|
|
94
|
+
// overrides an explicit opt-out nor unloads this tick's macOS job.
|
|
95
|
+
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
96
|
+
return checkAutostartState(context);
|
|
97
|
+
},
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
if (!receipt)
|
|
101
|
+
throw new Error("doctor_receipt_missing");
|
|
102
|
+
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
103
|
+
await fs.writeFile(`${outbox}.tmp`, JSON.stringify(receipt), { mode: 0o600 });
|
|
104
|
+
await fs.rename(`${outbox}.tmp`, outbox);
|
|
105
|
+
console.error("[sync-doctor] run complete", JSON.stringify({ reason: "receipt_saved", request_id: receipt.request_id, rows: receipt.repairs.length }));
|
|
106
|
+
if (await postReceipt(receipt, command, io, dashboardUrl)) {
|
|
107
|
+
await fs.rm(outbox, { force: true });
|
|
108
|
+
if (requestId === requestedDoctorId())
|
|
109
|
+
clearDoctorRequest();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
console.error("[sync-doctor] run will retry next tick", JSON.stringify({ reason: "doctor_followup_failed", error_name: error instanceof Error ? error.name : typeof error }));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await lock.handle.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function safeVerdict(value) {
|
|
120
|
+
const [status, code] = (value ?? "fail:receipt_missing").split(":");
|
|
121
|
+
return `${status}:${sanitizeEventCode(code ?? "receipt_missing")}`;
|
|
122
|
+
}
|
|
123
|
+
async function postReceipt(receipt, command, io, dashboardUrl) {
|
|
124
|
+
if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
|
|
125
|
+
console.error("[sync-doctor] checkout receipt retained locally", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
|
|
129
|
+
if (!session?.device_token || !session.device_id) {
|
|
130
|
+
console.error("[sync-doctor] receipt retained", JSON.stringify({ reason: "device_session_missing" }));
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const response = await io.fetch(`${dashboardUrl}/api/ambient/devices/${encodeURIComponent(session.device_id)}/doctor`, {
|
|
135
|
+
method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.device_token}` },
|
|
136
|
+
body: JSON.stringify(receipt), signal: AbortSignal.timeout(5_000),
|
|
137
|
+
});
|
|
138
|
+
console.error("[sync-doctor] receipt delivery", JSON.stringify({ reason: response.ok ? "receipt_recorded" : "receipt_rejected", http_status: response.status, request_id: receipt.request_id }));
|
|
139
|
+
return response.ok;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
console.error("[sync-doctor] receipt retained", JSON.stringify({ reason: "receipt_transport_failed", request_id: receipt.request_id }));
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -103,7 +103,7 @@ async function runSyncTick(command, io, tick) {
|
|
|
103
103
|
// so it is what separates a quiet machine from a dead one.
|
|
104
104
|
await tick.heartbeat(() => sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat));
|
|
105
105
|
await tick.outcomeReport(() => reportTickOutcome(command, io, dashboardUrl, result.completion));
|
|
106
|
-
await tick.converge(() => convergeAfterTick(command, io, dashboardUrl));
|
|
106
|
+
await tick.converge(() => convergeAfterTick(command, io, dashboardUrl, result.heartbeat));
|
|
107
107
|
// BLI-3619 / BLI-11106: every tick, the staged copies of whatever the
|
|
108
108
|
// server just committed go; a follow-up never blocks or fails collection.
|
|
109
109
|
await tick.prune(() => runStagingPruneAfterSync(command, io, dashboardUrl));
|
|
@@ -119,7 +119,7 @@ async function runSyncTick(command, io, tick) {
|
|
|
119
119
|
reason: errorCode,
|
|
120
120
|
}));
|
|
121
121
|
await tick.outcomeReport(() => reportTickThrew(command, io, dashboardUrl, error, errorCode));
|
|
122
|
-
await tick.converge(() => convergeAfterTick(command, io, dashboardUrl));
|
|
122
|
+
await tick.converge(() => convergeAfterTick(command, io, dashboardUrl, { status: "fail", reason: errorCode }));
|
|
123
123
|
await tick.prune(() => runStagingPruneAfterSync(command, io, dashboardUrl));
|
|
124
124
|
throw error;
|
|
125
125
|
}
|
|
@@ -186,11 +186,12 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
|
|
|
186
186
|
* Memory's registration — each at most once a day, each with its own receipt,
|
|
187
187
|
* and none of them able to block or fail collection (BLI-3580).
|
|
188
188
|
*/
|
|
189
|
-
async function convergeAfterTick(command, io, dashboardUrl) {
|
|
189
|
+
async function convergeAfterTick(command, io, dashboardUrl, facts) {
|
|
190
190
|
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
191
191
|
await runMemoryInstallAfterSync(command, io, dashboardUrl);
|
|
192
192
|
// BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
|
|
193
193
|
// sign-in), so nobody has to be told to run it.
|
|
194
194
|
await runMachineHealAfterSync(command, io, dashboardUrl);
|
|
195
|
+
await io.runDoctorAfterSync?.(command, io, dashboardUrl, {}, facts);
|
|
195
196
|
await runMemoryExperienceAfterSync({ ...command, dashboardUrl }, io);
|
|
196
197
|
}
|
package/dist/commands/team.js
CHANGED
|
@@ -31,6 +31,8 @@ export async function runTeam(command, io) {
|
|
|
31
31
|
return changeRole(command, tower, io);
|
|
32
32
|
if (command.action === "device-list")
|
|
33
33
|
return deviceList(command, tower, io);
|
|
34
|
+
if (command.action === "device-doctor")
|
|
35
|
+
return deviceDoctor(command, tower, io);
|
|
34
36
|
return deviceRevoke(command, tower, io);
|
|
35
37
|
}
|
|
36
38
|
async function showMembers(command, tower, io) {
|
|
@@ -188,4 +190,21 @@ async function deviceRevoke(command, tower, io) {
|
|
|
188
190
|
}
|
|
189
191
|
writeLine(io.stdout, renderDeviceRevoked(body));
|
|
190
192
|
return 0;
|
|
193
|
+
}
|
|
194
|
+
async function deviceDoctor(command, tower, io) {
|
|
195
|
+
const listed = await callTower(tower, { path: "/api/team/devices", label: "team device doctor lookup" });
|
|
196
|
+
if (!listed.ok)
|
|
197
|
+
return writeCommandFailure(io, command.json, listed);
|
|
198
|
+
const device = findDevice(devicesOf(asRecord(listed.body)), command.deviceRef ?? "");
|
|
199
|
+
if (!device)
|
|
200
|
+
return writeCommandFailure(io, command.json, { reason: "device_not_found", detail: "No unique device matches. Run cockpit team device list to see ids and names." });
|
|
201
|
+
const result = await callTower(tower, {
|
|
202
|
+
path: `/api/ambient/devices/${encodeURIComponent(device.device_id)}/doctor`,
|
|
203
|
+
method: "POST", label: "team device doctor", body: { action: "doctor" },
|
|
204
|
+
});
|
|
205
|
+
console.error("[team cli] device doctor", JSON.stringify({ device_id: device.device_id, reason: result.ok ? "requested" : result.reason }));
|
|
206
|
+
if (!result.ok)
|
|
207
|
+
return writeCommandFailure(io, command.json, result);
|
|
208
|
+
writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...asRecord(result.body) }) : `Doctor requested for ${device.device_name ?? device.device_id}. Waiting for its next check-in.`);
|
|
209
|
+
return 0;
|
|
191
210
|
}
|
|
@@ -53,6 +53,7 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
53
53
|
oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
|
|
54
54
|
last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
|
|
55
55
|
stuck_evidence_object_count: stuckEvidence.stuck_object_count,
|
|
56
|
+
stuck_evidence_ghost_record_count: stuckEvidence.ghost_record_count,
|
|
56
57
|
stuck_evidence_held_count: stuckEvidence.held_object_count,
|
|
57
58
|
stuck_evidence_max_attempts: stuckEvidence.max_attempts,
|
|
58
59
|
stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
|
|
@@ -148,7 +149,7 @@ function describeCollectorStatus(reading) {
|
|
|
148
149
|
details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
|
|
149
150
|
}
|
|
150
151
|
if (uploadSpool.pending_upload_count > 0) {
|
|
151
|
-
details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s)
|
|
152
|
+
details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) are saved on this machine and go out on the next background sync.`);
|
|
152
153
|
}
|
|
153
154
|
if (healthOutbox.pending_count > 0) {
|
|
154
155
|
details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
|
|
@@ -168,6 +169,9 @@ function describeCollectorStatus(reading) {
|
|
|
168
169
|
if (stuckEvidence.stuck_object_count > 0) {
|
|
169
170
|
details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
|
|
170
171
|
}
|
|
172
|
+
if (stuckEvidence.ghost_record_count > 0) {
|
|
173
|
+
details.push(`${stuckEvidence.ghost_record_count} old delivery record(s) have nothing left to send; no action needed.`);
|
|
174
|
+
}
|
|
171
175
|
return details;
|
|
172
176
|
}
|
|
173
177
|
/**
|
|
@@ -20,7 +20,26 @@ import { deliveryRetryAtMs, terminalDeliveryReason } from "./raw-evidence-stagin
|
|
|
20
20
|
* read green while an object has been failing for nine days.
|
|
21
21
|
*/
|
|
22
22
|
export function summarizeStuckEvidence(state, now) {
|
|
23
|
-
const entries =
|
|
23
|
+
const entries = [];
|
|
24
|
+
let ghostCount = 0;
|
|
25
|
+
for (const [hash, entry] of Object.entries(state.delivery_attempts)) {
|
|
26
|
+
if (terminalDeliveryReason(entry.last_reason))
|
|
27
|
+
continue;
|
|
28
|
+
// A row whose object left staging (pruned after commit, or reconciled by a
|
|
29
|
+
// committed 409) has nothing to offer; counting it as stuck was BLI-12910.
|
|
30
|
+
if (!state.staged[hash]) {
|
|
31
|
+
ghostCount += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
entries.push(entry);
|
|
35
|
+
}
|
|
36
|
+
if (ghostCount > 0) {
|
|
37
|
+
console.error("[raw-evidence-stuck] delivery records with no staged object left out of the stuck count", JSON.stringify({
|
|
38
|
+
ghost_record_count: ghostCount,
|
|
39
|
+
stuck_object_count: entries.length,
|
|
40
|
+
reason: "object_no_longer_staged",
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
24
43
|
const reasons = new Set();
|
|
25
44
|
let heldCount = 0;
|
|
26
45
|
let maxAttempts = 0;
|
|
@@ -52,6 +71,7 @@ export function summarizeStuckEvidence(state, now) {
|
|
|
52
71
|
return {
|
|
53
72
|
stuck_object_count: entries.length,
|
|
54
73
|
held_object_count: heldCount,
|
|
74
|
+
ghost_record_count: ghostCount,
|
|
55
75
|
max_attempts: maxAttempts,
|
|
56
76
|
oldest_first_failed_at: oldest,
|
|
57
77
|
reasons: [...reasons].sort(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.183",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.55",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.97",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.66"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|