@bli-cockpit/cli 0.2.149 → 0.2.154
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/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +1 -0
- package/dist/adapters/raw-evidence-facts.js +1 -0
- package/dist/adapters/raw-evidence-manifest.js +5 -0
- package/dist/adapters/raw-evidence-sanitize.js +82 -20
- package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
- package/dist/adapters/raw-evidence-transcript-file.js +10 -0
- package/dist/agent-rules.js +3 -3
- package/dist/commands/agent-door.js +3 -0
- package/dist/commands/backfill-lock-wait.js +49 -0
- package/dist/commands/backfill-outside-roots.js +97 -0
- package/dist/commands/backfill-reasons.js +17 -0
- package/dist/commands/backfill-scan.js +8 -3
- package/dist/commands/backfill-scope.js +14 -1
- package/dist/commands/backfill-session-report.js +3 -0
- package/dist/commands/backfill-upload.js +6 -0
- package/dist/commands/backfill.js +11 -5
- package/dist/commands/cal-calendars-write.js +107 -0
- package/dist/commands/cal.js +29 -110
- package/dist/commands/docs.js +20 -4
- package/dist/commands/doctor-budget.js +69 -0
- package/dist/commands/doctor-lock-wait.js +12 -3
- package/dist/commands/doctor-pipeline-verdicts.js +2 -0
- package/dist/commands/doctor-pipeline.js +5 -2
- package/dist/commands/doctor.js +26 -4
- package/dist/commands/issue-directory.js +7 -0
- package/dist/commands/issue.js +23 -4
- package/dist/commands/local-arg-values.js +9 -0
- package/dist/commands/local-args-collector-setup.js +4 -2
- package/dist/commands/local-args-collector-work.js +9 -1
- package/dist/commands/local-help-commands.js +4 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +14 -0
- package/dist/commands/mail.js +20 -4
- package/dist/commands/memory-store.js +27 -10
- package/dist/commands/msg.js +25 -5
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/read-cache.js +115 -0
- package/dist/commands/settings.js +17 -2
- package/dist/commands/tower-command.js +4 -0
- package/dist/cursors/raw-evidence-cursor.js +14 -0
- package/dist/tower-client.js +31 -3
- package/dist/upload-envelope-build.js +9 -0
- package/dist/upload-sync.js +10 -22
- package/dist/warm-tower-fetch.js +83 -0
- package/package.json +5 -5
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
3
|
+
const TAG = "[cal cli]";
|
|
4
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
5
|
+
/** A first read of a crowded calendar can take a while. */
|
|
6
|
+
const SYNC_DEADLINE_MS = 120_000;
|
|
7
|
+
const URL_MAX_CHARS = 2_000;
|
|
8
|
+
export async function runCalAddIcal(command, door) {
|
|
9
|
+
// The address comes from stdin and nowhere else. An interactive terminal
|
|
10
|
+
// with nothing piped in is told how, rather than left waiting on a prompt
|
|
11
|
+
// that would echo a permanent credential into the scrollback.
|
|
12
|
+
if (isInteractiveStdin(door.io)) {
|
|
13
|
+
return failAgentDoor(door, TAG, "address_required_on_stdin", 'The calendar\'s secret address is read from stdin, never from a flag. Try: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"');
|
|
14
|
+
}
|
|
15
|
+
let icalUrl;
|
|
16
|
+
try {
|
|
17
|
+
icalUrl = (await readPipedText(door.io.stdin, {
|
|
18
|
+
maxChars: URL_MAX_CHARS,
|
|
19
|
+
overflowMessage: "That is longer than any calendar address; nothing was attached.",
|
|
20
|
+
})).trim();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return failAgentDoor(door, TAG, "address_unreadable", error instanceof Error ? error.message : String(error));
|
|
24
|
+
}
|
|
25
|
+
if (icalUrl === "") {
|
|
26
|
+
return failAgentDoor(door, TAG, "address_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
|
|
27
|
+
}
|
|
28
|
+
const answer = await askAgentDoor(door, {
|
|
29
|
+
path: "/api/cal/calendars",
|
|
30
|
+
method: "POST",
|
|
31
|
+
label: "cal add-ical",
|
|
32
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
33
|
+
body: {
|
|
34
|
+
ical_url: icalUrl,
|
|
35
|
+
name: command.name ?? null,
|
|
36
|
+
...(command.orgVisible ? { org_visible: true } : {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (!answer.ok)
|
|
40
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
41
|
+
const calendar = answer.body.calendar;
|
|
42
|
+
if (door.json)
|
|
43
|
+
return emitAgentDoor(door, { ok: true, calendar });
|
|
44
|
+
writeLine(door.io.stdout, `Attached (${calendar?.id ?? "no id returned"}).`);
|
|
45
|
+
writeLine(door.io.stdout, `Nothing is read until a sync runs: \`cockpit cal sync ${calendar?.id ?? "<id>"}\`, or wait for the cron.`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
export async function runCalShare(command, door) {
|
|
49
|
+
const orgVisible = command.orgVisible && !command.makePrivate;
|
|
50
|
+
const answer = await askAgentDoor(door, {
|
|
51
|
+
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
52
|
+
method: "PATCH",
|
|
53
|
+
label: "cal share",
|
|
54
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
55
|
+
body: { org_visible: orgVisible },
|
|
56
|
+
});
|
|
57
|
+
if (!answer.ok)
|
|
58
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
59
|
+
const calendar = answer.body.calendar;
|
|
60
|
+
if (door.json)
|
|
61
|
+
return emitAgentDoor(door, { ok: true, calendar });
|
|
62
|
+
writeLine(door.io.stdout, orgVisible
|
|
63
|
+
? `"${calendar?.name ?? command.subjectId}" is now readable by everyone at BLI. Nobody but you can change it.`
|
|
64
|
+
: `"${calendar?.name ?? command.subjectId}" is private again — only you can read it.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
export async function runCalDetach(command, door) {
|
|
68
|
+
const answer = await askAgentDoor(door, {
|
|
69
|
+
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
70
|
+
method: "DELETE",
|
|
71
|
+
label: "cal detach",
|
|
72
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
73
|
+
});
|
|
74
|
+
if (!answer.ok)
|
|
75
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
76
|
+
if (door.json)
|
|
77
|
+
return emitAgentDoor(door, { ok: true, detached: command.subjectId });
|
|
78
|
+
writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored events went with it; the calendar itself is untouched at Google.`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
export async function runCalSync(command, door) {
|
|
82
|
+
const answer = await askAgentDoor(door, {
|
|
83
|
+
path: `/api/cal/calendars/${encodeURIComponent(command.subjectId ?? "")}/sync${command.full ? "?full=1" : ""}`,
|
|
84
|
+
method: "POST",
|
|
85
|
+
label: "cal sync",
|
|
86
|
+
timeoutMs: SYNC_DEADLINE_MS,
|
|
87
|
+
});
|
|
88
|
+
if (!answer.ok)
|
|
89
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
90
|
+
const run = answer.body.run;
|
|
91
|
+
if (door.json)
|
|
92
|
+
return emitAgentDoor(door, { ok: true, run });
|
|
93
|
+
if (!run) {
|
|
94
|
+
writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (run.reason === "ok") {
|
|
98
|
+
writeLine(door.io.stdout, run.detail === "feed_unchanged"
|
|
99
|
+
? "Nothing has changed on that calendar since the last read."
|
|
100
|
+
: `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
// A failure names itself; the exit code says it failed.
|
|
104
|
+
writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
|
|
105
|
+
writeLine(door.io.stdout, `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -21,15 +21,14 @@
|
|
|
21
21
|
* computed in UTC and "today" would end at 5pm in Vancouver.
|
|
22
22
|
*/
|
|
23
23
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
24
|
+
import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
|
|
24
25
|
import { runCalDelete } from "./cal-delete.js";
|
|
25
26
|
import { runCalSyncStatus } from "./cal-sync-status.js";
|
|
26
|
-
import {
|
|
27
|
+
import { writeLine } from "./cli-io.js";
|
|
28
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
27
29
|
const TAG = "[cal cli]";
|
|
28
30
|
const READ_DEADLINE_MS = 30_000;
|
|
29
31
|
const WRITE_DEADLINE_MS = 60_000;
|
|
30
|
-
/** A first read of a crowded calendar can take a while. */
|
|
31
|
-
const SYNC_DEADLINE_MS = 120_000;
|
|
32
|
-
const URL_MAX_CHARS = 2_000;
|
|
33
32
|
export async function runCal(command, io) {
|
|
34
33
|
const door = await openAgentDoor("cal", command, io);
|
|
35
34
|
switch (command.action) {
|
|
@@ -42,15 +41,15 @@ export async function runCal(command, io) {
|
|
|
42
41
|
case "calendars":
|
|
43
42
|
return listCalendars(door);
|
|
44
43
|
case "add-ical":
|
|
45
|
-
return
|
|
44
|
+
return runCalAddIcal(command, door);
|
|
46
45
|
case "create":
|
|
47
46
|
return create(command, door);
|
|
48
47
|
case "share":
|
|
49
|
-
return
|
|
48
|
+
return runCalShare(command, door);
|
|
50
49
|
case "detach":
|
|
51
|
-
return
|
|
50
|
+
return runCalDetach(command, door);
|
|
52
51
|
case "sync":
|
|
53
|
-
return
|
|
52
|
+
return runCalSync(command, door);
|
|
54
53
|
case "sync-status":
|
|
55
54
|
return runCalSyncStatus(command, door);
|
|
56
55
|
case "delete":
|
|
@@ -92,17 +91,37 @@ async function window(command, door) {
|
|
|
92
91
|
// `--from`/`--to` beat a named window, on any of the three verbs: somebody
|
|
93
92
|
// who named two dates meant them.
|
|
94
93
|
const path = command.from && command.to ? "events" : command.action;
|
|
95
|
-
const
|
|
96
|
-
|
|
94
|
+
const apiPath = `/api/cal/${path}?${windowQuery(command).toString()}`;
|
|
95
|
+
// BLI-10746: `cal today` is the one read this ticket measured; the warm
|
|
96
|
+
// route and the cache both apply to it (and only it).
|
|
97
|
+
const cached = command.action === "today";
|
|
98
|
+
const ask = () => askAgentDoor(door, {
|
|
99
|
+
path: apiPath,
|
|
97
100
|
method: "GET",
|
|
98
101
|
label: `cal ${command.action}`,
|
|
99
102
|
timeoutMs: READ_DEADLINE_MS,
|
|
103
|
+
warmRoute: cached,
|
|
100
104
|
});
|
|
105
|
+
const answer = cached
|
|
106
|
+
? await readThroughCache({
|
|
107
|
+
io: door.io,
|
|
108
|
+
homeDir: door.homeDir,
|
|
109
|
+
deviceToken: door.deviceToken,
|
|
110
|
+
surface: "cal",
|
|
111
|
+
label: "cal today",
|
|
112
|
+
path: apiPath,
|
|
113
|
+
json: door.json,
|
|
114
|
+
ask,
|
|
115
|
+
})
|
|
116
|
+
: { ...(await ask()), fromCache: false, ageMs: null };
|
|
101
117
|
if (!answer.ok)
|
|
102
118
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
103
119
|
const body = answer.body;
|
|
104
120
|
if (door.json)
|
|
105
121
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
122
|
+
if (answer.fromCache) {
|
|
123
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
124
|
+
}
|
|
106
125
|
const zone = body.window?.time_zone ?? localTimeZone();
|
|
107
126
|
const events = body.events ?? [];
|
|
108
127
|
if (events.length === 0) {
|
|
@@ -246,46 +265,6 @@ async function listCalendars(door) {
|
|
|
246
265
|
writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
|
|
247
266
|
return 0;
|
|
248
267
|
}
|
|
249
|
-
async function addIcal(command, door) {
|
|
250
|
-
// The address comes from stdin and nowhere else. An interactive terminal
|
|
251
|
-
// with nothing piped in is told how, rather than left waiting on a prompt
|
|
252
|
-
// that would echo a permanent credential into the scrollback.
|
|
253
|
-
if (isInteractiveStdin(door.io)) {
|
|
254
|
-
return failAgentDoor(door, TAG, "address_required_on_stdin", 'The calendar\'s secret address is read from stdin, never from a flag. Try: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"');
|
|
255
|
-
}
|
|
256
|
-
let icalUrl;
|
|
257
|
-
try {
|
|
258
|
-
icalUrl = (await readPipedText(door.io.stdin, {
|
|
259
|
-
maxChars: URL_MAX_CHARS,
|
|
260
|
-
overflowMessage: "That is longer than any calendar address; nothing was attached.",
|
|
261
|
-
})).trim();
|
|
262
|
-
}
|
|
263
|
-
catch (error) {
|
|
264
|
-
return failAgentDoor(door, TAG, "address_unreadable", error instanceof Error ? error.message : String(error));
|
|
265
|
-
}
|
|
266
|
-
if (icalUrl === "") {
|
|
267
|
-
return failAgentDoor(door, TAG, "address_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
|
|
268
|
-
}
|
|
269
|
-
const answer = await askAgentDoor(door, {
|
|
270
|
-
path: "/api/cal/calendars",
|
|
271
|
-
method: "POST",
|
|
272
|
-
label: "cal add-ical",
|
|
273
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
274
|
-
body: {
|
|
275
|
-
ical_url: icalUrl,
|
|
276
|
-
name: command.name ?? null,
|
|
277
|
-
...(command.orgVisible ? { org_visible: true } : {}),
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
if (!answer.ok)
|
|
281
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
282
|
-
const calendar = answer.body.calendar;
|
|
283
|
-
if (door.json)
|
|
284
|
-
return emitAgentDoor(door, { ok: true, calendar });
|
|
285
|
-
writeLine(door.io.stdout, `Attached (${calendar?.id ?? "no id returned"}).`);
|
|
286
|
-
writeLine(door.io.stdout, `Nothing is read until a sync runs: \`cockpit cal sync ${calendar?.id ?? "<id>"}\`, or wait for the cron.`);
|
|
287
|
-
return 0;
|
|
288
|
-
}
|
|
289
268
|
async function create(command, door) {
|
|
290
269
|
const answer = await askAgentDoor(door, {
|
|
291
270
|
path: "/api/cal/events",
|
|
@@ -328,64 +307,4 @@ async function create(command, door) {
|
|
|
328
307
|
writeLine(door.io.stdout, `Invitations went to ${command.attendees.join(", ")} from Google.`);
|
|
329
308
|
}
|
|
330
309
|
return 0;
|
|
331
|
-
}
|
|
332
|
-
async function share(command, door) {
|
|
333
|
-
const orgVisible = command.orgVisible && !command.makePrivate;
|
|
334
|
-
const answer = await askAgentDoor(door, {
|
|
335
|
-
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
336
|
-
method: "PATCH",
|
|
337
|
-
label: "cal share",
|
|
338
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
339
|
-
body: { org_visible: orgVisible },
|
|
340
|
-
});
|
|
341
|
-
if (!answer.ok)
|
|
342
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
343
|
-
const calendar = answer.body.calendar;
|
|
344
|
-
if (door.json)
|
|
345
|
-
return emitAgentDoor(door, { ok: true, calendar });
|
|
346
|
-
writeLine(door.io.stdout, orgVisible
|
|
347
|
-
? `"${calendar?.name ?? command.subjectId}" is now readable by everyone at BLI. Nobody but you can change it.`
|
|
348
|
-
: `"${calendar?.name ?? command.subjectId}" is private again — only you can read it.`);
|
|
349
|
-
return 0;
|
|
350
|
-
}
|
|
351
|
-
async function detach(command, door) {
|
|
352
|
-
const answer = await askAgentDoor(door, {
|
|
353
|
-
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
354
|
-
method: "DELETE",
|
|
355
|
-
label: "cal detach",
|
|
356
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
357
|
-
});
|
|
358
|
-
if (!answer.ok)
|
|
359
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
360
|
-
if (door.json)
|
|
361
|
-
return emitAgentDoor(door, { ok: true, detached: command.subjectId });
|
|
362
|
-
writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored events went with it; the calendar itself is untouched at Google.`);
|
|
363
|
-
return 0;
|
|
364
|
-
}
|
|
365
|
-
async function sync(command, door) {
|
|
366
|
-
const answer = await askAgentDoor(door, {
|
|
367
|
-
path: `/api/cal/calendars/${encodeURIComponent(command.subjectId ?? "")}/sync${command.full ? "?full=1" : ""}`,
|
|
368
|
-
method: "POST",
|
|
369
|
-
label: "cal sync",
|
|
370
|
-
timeoutMs: SYNC_DEADLINE_MS,
|
|
371
|
-
});
|
|
372
|
-
if (!answer.ok)
|
|
373
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
374
|
-
const run = answer.body.run;
|
|
375
|
-
if (door.json)
|
|
376
|
-
return emitAgentDoor(door, { ok: true, run });
|
|
377
|
-
if (!run) {
|
|
378
|
-
writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
|
|
379
|
-
return 0;
|
|
380
|
-
}
|
|
381
|
-
if (run.reason === "ok") {
|
|
382
|
-
writeLine(door.io.stdout, run.detail === "feed_unchanged"
|
|
383
|
-
? "Nothing has changed on that calendar since the last read."
|
|
384
|
-
: `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`);
|
|
385
|
-
return 0;
|
|
386
|
-
}
|
|
387
|
-
// A failure names itself; the exit code says it failed.
|
|
388
|
-
writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
|
|
389
|
-
writeLine(door.io.stdout, `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
|
|
390
|
-
return 1;
|
|
391
310
|
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -32,6 +32,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
|
32
32
|
import { readDoc } from "./docs-read.js";
|
|
33
33
|
import { linkDoc, syncStatusDocs } from "./docs-notion.js";
|
|
34
34
|
import { readNoteFile } from "./notes-file.js";
|
|
35
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
35
36
|
const TAG = "[docs cli]";
|
|
36
37
|
const READ_DEADLINE_MS = 30_000;
|
|
37
38
|
const WRITE_DEADLINE_MS = 60_000;
|
|
@@ -74,17 +75,32 @@ export function docsListPath(command) {
|
|
|
74
75
|
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
75
76
|
}
|
|
76
77
|
async function listDocs(command, door) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
const listPath = docsListPath(command);
|
|
79
|
+
const answer = await readThroughCache({
|
|
80
|
+
io: door.io,
|
|
81
|
+
homeDir: door.homeDir,
|
|
82
|
+
deviceToken: door.deviceToken,
|
|
83
|
+
surface: "docs",
|
|
80
84
|
label: "docs list",
|
|
81
|
-
|
|
85
|
+
path: listPath,
|
|
86
|
+
json: door.json,
|
|
87
|
+
ask: () => askAgentDoor(door, {
|
|
88
|
+
path: listPath,
|
|
89
|
+
method: "GET",
|
|
90
|
+
label: "docs list",
|
|
91
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
92
|
+
// BLI-10746: route through the resident memory daemon's warm connection.
|
|
93
|
+
warmRoute: true,
|
|
94
|
+
}),
|
|
82
95
|
});
|
|
83
96
|
if (!answer.ok)
|
|
84
97
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
85
98
|
const documents = (answer.body.documents ?? []);
|
|
86
99
|
if (door.json)
|
|
87
100
|
return emitAgentDoor(door, { ok: true, documents });
|
|
101
|
+
if (answer.fromCache) {
|
|
102
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
103
|
+
}
|
|
88
104
|
if (documents.length === 0) {
|
|
89
105
|
writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
|
|
90
106
|
return 0;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-11101: `cockpit doctor --json` answers within one bounded budget.
|
|
3
|
+
*
|
|
4
|
+
* A person at a terminal gets the long defaults (a 900-second catch-up and a
|
|
5
|
+
* 600-second wait for the collection lock) with progress every 30 seconds. An
|
|
6
|
+
* agent or script calling `--json` gets one answer: on Edward's Mac the long
|
|
7
|
+
* defaults ran past 9.5 minutes behind the 15-minute scheduled sync, and a
|
|
8
|
+
* caller that times out learns nothing. So `--json` without `--lock-wait` or
|
|
9
|
+
* `--backfill-budget` runs every repair under one deadline measured from the
|
|
10
|
+
* start of the run, and a repair that does not fit says so by name instead of
|
|
11
|
+
* queueing. Passing either flag opts back into the long run.
|
|
12
|
+
*/
|
|
13
|
+
export const DOCTOR_JSON_BUDGET_SECONDS = 45;
|
|
14
|
+
export const DEFAULT_LOCK_WAIT_SECONDS = 600;
|
|
15
|
+
export const DEFAULT_BACKFILL_BUDGET_SECONDS = 900;
|
|
16
|
+
/** A repair that execs a whole sync or catch-up chunk is not started with less than this left. */
|
|
17
|
+
export const SLOW_FIX_MIN_SECONDS = {
|
|
18
|
+
"backfill-complete": 10,
|
|
19
|
+
"sync-fresh": 15,
|
|
20
|
+
};
|
|
21
|
+
/** The run deadline in epoch ms, or `null` when the caller gets the long defaults. */
|
|
22
|
+
export function doctorRunDeadline(command, startedMs) {
|
|
23
|
+
if (!command.json)
|
|
24
|
+
return null;
|
|
25
|
+
if (command.lockWaitSeconds !== undefined || command.backfillBudgetSeconds !== undefined)
|
|
26
|
+
return null;
|
|
27
|
+
return startedMs + DOCTOR_JSON_BUDGET_SECONDS * 1000;
|
|
28
|
+
}
|
|
29
|
+
function remainingSeconds(context, now) {
|
|
30
|
+
if (context.deadlineMs === undefined || context.deadlineMs === null)
|
|
31
|
+
return null;
|
|
32
|
+
return Math.max(0, (context.deadlineMs - now) / 1000);
|
|
33
|
+
}
|
|
34
|
+
/** How long a repair may wait for the collection lock. */
|
|
35
|
+
export function doctorLockWaitSeconds(context, now = Date.now()) {
|
|
36
|
+
if (context.command.lockWaitSeconds !== undefined)
|
|
37
|
+
return context.command.lockWaitSeconds;
|
|
38
|
+
const remaining = remainingSeconds(context, now);
|
|
39
|
+
return remaining === null ? DEFAULT_LOCK_WAIT_SECONDS : Math.min(DEFAULT_LOCK_WAIT_SECONDS, remaining);
|
|
40
|
+
}
|
|
41
|
+
/** How long the catch-up repair may run, lock waiting included. */
|
|
42
|
+
export function doctorBackfillBudgetSeconds(context, now = Date.now()) {
|
|
43
|
+
if (context.command.backfillBudgetSeconds !== undefined)
|
|
44
|
+
return context.command.backfillBudgetSeconds;
|
|
45
|
+
const remaining = remainingSeconds(context, now);
|
|
46
|
+
return remaining === null ? DEFAULT_BACKFILL_BUDGET_SECONDS : Math.min(DEFAULT_BACKFILL_BUDGET_SECONDS, remaining);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The row a slow repair returns instead of starting when the run budget cannot
|
|
50
|
+
* hold it; `null` means go ahead. Logged either way the budget decides.
|
|
51
|
+
*/
|
|
52
|
+
export function budgetSpentRow(context, row, now = Date.now()) {
|
|
53
|
+
const floor = SLOW_FIX_MIN_SECONDS[row.id];
|
|
54
|
+
const remaining = remainingSeconds(context, now);
|
|
55
|
+
if (floor === undefined || remaining === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (remaining >= floor) {
|
|
58
|
+
console.error("[doctor] repair fits the json budget", JSON.stringify({ row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor }));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
console.error("[doctor] repair skipped, json budget spent", JSON.stringify({ reason: "doctor_budget_spent", row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor, budget_seconds: DOCTOR_JSON_BUDGET_SECONDS }));
|
|
62
|
+
return {
|
|
63
|
+
...row,
|
|
64
|
+
status: "needs_fix",
|
|
65
|
+
code: "doctor_budget_spent",
|
|
66
|
+
nextAction: "cockpit doctor",
|
|
67
|
+
message: `\`cockpit doctor --json\` answers within ${DOCTOR_JSON_BUDGET_SECONDS}s and this repair needs at least ${floor}s; ${Math.floor(remaining)}s were left (was: ${row.code}). Run \`cockpit doctor\` to let it finish.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
2
2
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
3
3
|
import { inspectSyncLock } from "../sync-lock.js";
|
|
4
|
-
|
|
4
|
+
import { doctorLockWaitSeconds } from "./doctor-budget.js";
|
|
5
|
+
const BUSY = new Set(["sync_already_running", "sync_lock_held", "backfill_already_running", "live_sync_paused_during_backfill"]);
|
|
5
6
|
/** Retry the operation itself: its existing exclusive lock acquisition owns the
|
|
6
7
|
* handoff. Merely observing a free lock never grants permission to collect. */
|
|
7
8
|
export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
8
9
|
const started = timing.now();
|
|
9
|
-
const limit = (context
|
|
10
|
+
const limit = doctorLockWaitSeconds(context) * 1000;
|
|
10
11
|
let nextProgress = 0;
|
|
11
12
|
let row = await run();
|
|
12
13
|
while (BUSY.has(row.code)) {
|
|
@@ -27,7 +28,15 @@ export async function withDoctorLockWait(context, run, timing = { now: () => Dat
|
|
|
27
28
|
if (result.code === 0 && result.stdout.trim())
|
|
28
29
|
name = result.stdout.trim().split("\n")[0] ?? name;
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
// BLI-11101: the scheduled sync holding the lock is not a fault; it is
|
|
32
|
+
// doing the upload this row would prove. Say so by name.
|
|
33
|
+
const syncOwns = !backfill.held && Boolean(sync?.held);
|
|
34
|
+
const code = syncOwns ? "sync_running" : "lock_wait_timeout";
|
|
35
|
+
console.error(`[doctor] ${row.id} lock wait ended`, JSON.stringify({ reason: code, owner_pid: pid ?? null, waited_seconds: Math.round(elapsed / 1000), limit_seconds: Math.round(limit / 1000) }));
|
|
36
|
+
const message = syncOwns
|
|
37
|
+
? `The background sync (pid ${pid ?? "unknown"}, ${name}) is running and holds the collection lock; waited ${Math.round(elapsed / 1000)}s. Run \`cockpit doctor\` after it finishes, or \`cockpit doctor --lock-wait 600\` to wait for it.`
|
|
38
|
+
: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.`;
|
|
39
|
+
return { ...row, status: "needs_fix", code, nextAction: "cockpit doctor --lock-wait 600", message };
|
|
31
40
|
}
|
|
32
41
|
if (elapsed >= nextProgress) {
|
|
33
42
|
const seconds = Math.floor(elapsed / 1000);
|
|
@@ -43,6 +43,8 @@ const COLLECTION_BUSY_REASONS = new Set([
|
|
|
43
43
|
"live_sync_paused_during_backfill",
|
|
44
44
|
// `cockpit backfill` stood aside for another backfill.
|
|
45
45
|
"backfill_already_running",
|
|
46
|
+
// `cockpit backfill` waited for the collection lock and gave up (BLI-11091).
|
|
47
|
+
"sync_lock_held",
|
|
46
48
|
]);
|
|
47
49
|
export function isCollectionBusyReason(reason) {
|
|
48
50
|
return typeof reason === "string" && COLLECTION_BUSY_REASONS.has(reason);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withDoctorLockWait } from "./doctor-lock-wait.js";
|
|
2
|
+
import { doctorBackfillBudgetSeconds, doctorLockWaitSeconds } from "./doctor-budget.js";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
@@ -48,7 +49,7 @@ export async function checkBackfillState(context) {
|
|
|
48
49
|
}
|
|
49
50
|
export async function fixBackfillState(context) {
|
|
50
51
|
const started = Date.now();
|
|
51
|
-
const deadline = started + (context
|
|
52
|
+
const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
|
|
52
53
|
let caughtUp = 0;
|
|
53
54
|
let chunks = 0;
|
|
54
55
|
let live = null;
|
|
@@ -79,7 +80,7 @@ export async function fixBackfillState(context) {
|
|
|
79
80
|
// Every chunk reacquires the collector's locks. Bound lock waiting by
|
|
80
81
|
// the overall budget too; never abandon an in-flight cursor write.
|
|
81
82
|
const chunkContext = { ...context, command: { ...context.command,
|
|
82
|
-
lockWaitSeconds: Math.min(context
|
|
83
|
+
lockWaitSeconds: Math.min(doctorLockWaitSeconds(context), (deadline - Date.now()) / 1000),
|
|
83
84
|
} };
|
|
84
85
|
const row = await withDoctorLockWait(chunkContext, async () => {
|
|
85
86
|
if (Date.now() >= deadline)
|
|
@@ -91,6 +92,8 @@ export async function fixBackfillState(context) {
|
|
|
91
92
|
homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
|
|
92
93
|
all: true, dryRun: false, yes: true, json: true,
|
|
93
94
|
deadlineMs: deadline,
|
|
95
|
+
// Doctor's own `withDoctorLockWait` owns the waiting (BLI-11091).
|
|
96
|
+
syncLockWaitMs: 0,
|
|
94
97
|
onProgress: (progress) => { live = progress; },
|
|
95
98
|
}, capture.io);
|
|
96
99
|
const parsed = parseDoctorBackfillJson(capture.stdout());
|
package/dist/commands/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
8
|
+
import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
|
|
8
9
|
import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
|
|
9
10
|
import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
10
11
|
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
|
|
@@ -15,12 +16,19 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
|
15
16
|
return runDoctorWithDeps(command, io, deps);
|
|
16
17
|
}
|
|
17
18
|
export async function runDoctorWithDeps(command, io, deps) {
|
|
18
|
-
const
|
|
19
|
+
const runStarted = Date.now();
|
|
20
|
+
const deadlineMs = doctorRunDeadline(command, runStarted);
|
|
21
|
+
const context = { command, io, deps, deadlineMs };
|
|
22
|
+
console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
|
|
19
23
|
const rows = [];
|
|
20
24
|
const repairs = [];
|
|
21
25
|
const diagnoseOnly = command.dryRun || command.checkOnly;
|
|
22
26
|
const diagnosed = new Map();
|
|
27
|
+
// BLI-11101: every row says how long it took, so the next slow run names
|
|
28
|
+
// the row without a profiler. A killed run still leaves every finished row.
|
|
29
|
+
const checkMs = new Map();
|
|
23
30
|
for (const invariant of doctorInvariants()) {
|
|
31
|
+
const started = Date.now();
|
|
24
32
|
try {
|
|
25
33
|
diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
|
|
26
34
|
}
|
|
@@ -28,16 +36,24 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
28
36
|
console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
|
|
29
37
|
diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
|
|
30
38
|
}
|
|
39
|
+
checkMs.set(invariant.id, Date.now() - started);
|
|
40
|
+
console.error("[doctor] row checked", JSON.stringify({ row: invariant.id, elapsed_ms: checkMs.get(invariant.id), verdict: diagnosed.get(invariant.id)?.status, reason: diagnosed.get(invariant.id)?.code }));
|
|
31
41
|
}
|
|
32
42
|
for (const invariant of doctorInvariants()) {
|
|
33
43
|
let row;
|
|
44
|
+
const fixStarted = Date.now();
|
|
34
45
|
try {
|
|
35
46
|
row = diagnosed.get(invariant.id);
|
|
36
47
|
const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
|
|
37
48
|
const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
|
|
38
49
|
const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
|
|
39
50
|
(!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
|
|
40
|
-
|
|
51
|
+
const spent = row.status !== "ok" && row.status !== "skipped" && canFix ? budgetSpentRow(context, row) : null;
|
|
52
|
+
if (spent) {
|
|
53
|
+
row = spent;
|
|
54
|
+
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
55
|
+
}
|
|
56
|
+
else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
|
|
41
57
|
row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
|
|
42
58
|
repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
|
|
43
59
|
// The replacement process owns the one final receipt, including JSON.
|
|
@@ -57,6 +73,9 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
57
73
|
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
58
74
|
}
|
|
59
75
|
console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
|
|
76
|
+
const fixMs = Date.now() - fixStarted;
|
|
77
|
+
const rowCheckMs = checkMs.get(invariant.id) ?? 0;
|
|
78
|
+
console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
|
|
60
79
|
rows.push(row);
|
|
61
80
|
}
|
|
62
81
|
// Re-read host state after repairs. Sync is proven by its upload receipts;
|
|
@@ -68,10 +87,11 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
68
87
|
const index = rows.findIndex((row) => row.id === invariant.id);
|
|
69
88
|
if (rows[index]?.status === "fail")
|
|
70
89
|
continue;
|
|
90
|
+
const verifyStarted = Date.now();
|
|
71
91
|
try {
|
|
72
92
|
const checked = await invariant.check(context);
|
|
73
93
|
rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
|
|
74
|
-
console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
|
|
94
|
+
console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status, elapsed_ms: Date.now() - verifyStarted }));
|
|
75
95
|
if (checked.status === "needs_fix" || checked.status === "fail") {
|
|
76
96
|
const repair = repairs.find((entry) => entry.step === invariant.id);
|
|
77
97
|
if (repair) {
|
|
@@ -102,8 +122,10 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
102
122
|
rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
|
|
103
123
|
}
|
|
104
124
|
}
|
|
125
|
+
const needsPerson = doctorNeedsPerson(rows, receipt).length;
|
|
126
|
+
console.error("[doctor] run done", JSON.stringify({ elapsed_ms: Date.now() - runStarted, rows: rows.length, needs_person: needsPerson, check_only: Boolean(diagnoseOnly) }));
|
|
105
127
|
writeDoctorOutput(command, io, rows, receipt, repairs);
|
|
106
|
-
return
|
|
128
|
+
return needsPerson === 0 ? 0 : 1;
|
|
107
129
|
}
|
|
108
130
|
function doctorInvariants() {
|
|
109
131
|
return [
|
|
@@ -22,6 +22,13 @@ async function fetchTeamDirectory(door) {
|
|
|
22
22
|
method: "GET",
|
|
23
23
|
label: "issue assignee directory",
|
|
24
24
|
timeoutMs: TEAM_DIRECTORY_TIMEOUT_MS,
|
|
25
|
+
// BLI-10746: this runs IN PARALLEL with `work list`'s own issues fetch
|
|
26
|
+
// (BLI-10579) — measured live, an un-warmed directory call was the
|
|
27
|
+
// reason `work list`'s end-to-end time did not show the daemon's own
|
|
28
|
+
// ~300ms handshake saving (its own fetch dropped from 749ms cold to
|
|
29
|
+
// ~420-500ms warm; the command's total didn't, because this parallel
|
|
30
|
+
// call was still paying a cold connection every time).
|
|
31
|
+
warmRoute: true,
|
|
25
32
|
});
|
|
26
33
|
if (!answer.ok)
|
|
27
34
|
return { ok: false, people: [], reason: answer.reason };
|
package/dist/commands/issue.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
29
29
|
import { writeLine } from "./cli-io.js";
|
|
30
30
|
import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
31
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
31
32
|
import { showIssue } from "./issue-show.js";
|
|
32
33
|
import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
|
|
33
34
|
import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
|
|
@@ -111,11 +112,26 @@ async function listIssues(command, door) {
|
|
|
111
112
|
// instead of waiting for that answer first (measured ~580ms of the ~580ms
|
|
112
113
|
// `work list` p50 was exactly this serial second call).
|
|
113
114
|
const directoryFetch = beginAssigneeDirectoryFetch(door);
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const listPath = `/api/work/issues${suffix ? `?${suffix}` : ""}`;
|
|
116
|
+
const answer = await readThroughCache({
|
|
117
|
+
io: door.io,
|
|
118
|
+
homeDir: door.homeDir,
|
|
119
|
+
deviceToken: door.deviceToken,
|
|
120
|
+
surface: "work",
|
|
117
121
|
label: "issue list",
|
|
118
|
-
|
|
122
|
+
path: listPath,
|
|
123
|
+
json: door.json,
|
|
124
|
+
ask: () => askAgentDoor(door, {
|
|
125
|
+
path: listPath,
|
|
126
|
+
method: "GET",
|
|
127
|
+
label: "issue list",
|
|
128
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
129
|
+
// BLI-10746: `work list` was the slowest of the six reads measured
|
|
130
|
+
// from Vietnam (1,682 ms p50) — route it through the resident memory
|
|
131
|
+
// daemon's warm connection so this command skips a fresh TCP+TLS
|
|
132
|
+
// handshake.
|
|
133
|
+
warmRoute: true,
|
|
134
|
+
}),
|
|
119
135
|
});
|
|
120
136
|
if (!answer.ok) {
|
|
121
137
|
// Not awaited for its own sake — `finishAssigneeDirectory` always awaits
|
|
@@ -123,6 +139,9 @@ async function listIssues(command, door) {
|
|
|
123
139
|
await finishAssigneeDirectory(door, directoryFetch, []);
|
|
124
140
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
125
141
|
}
|
|
142
|
+
if (answer.fromCache && !door.json) {
|
|
143
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
144
|
+
}
|
|
126
145
|
const body = answer.body;
|
|
127
146
|
const issues = body.issues ?? [];
|
|
128
147
|
// BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
|