@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.
Files changed (46) hide show
  1. package/dist/adapters/local-sources.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +1 -0
  3. package/dist/adapters/raw-evidence-facts.js +1 -0
  4. package/dist/adapters/raw-evidence-manifest.js +5 -0
  5. package/dist/adapters/raw-evidence-sanitize.js +82 -20
  6. package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
  7. package/dist/adapters/raw-evidence-transcript-file.js +10 -0
  8. package/dist/agent-rules.js +3 -3
  9. package/dist/commands/agent-door.js +3 -0
  10. package/dist/commands/backfill-lock-wait.js +49 -0
  11. package/dist/commands/backfill-outside-roots.js +97 -0
  12. package/dist/commands/backfill-reasons.js +17 -0
  13. package/dist/commands/backfill-scan.js +8 -3
  14. package/dist/commands/backfill-scope.js +14 -1
  15. package/dist/commands/backfill-session-report.js +3 -0
  16. package/dist/commands/backfill-upload.js +6 -0
  17. package/dist/commands/backfill.js +11 -5
  18. package/dist/commands/cal-calendars-write.js +107 -0
  19. package/dist/commands/cal.js +29 -110
  20. package/dist/commands/docs.js +20 -4
  21. package/dist/commands/doctor-budget.js +69 -0
  22. package/dist/commands/doctor-lock-wait.js +12 -3
  23. package/dist/commands/doctor-pipeline-verdicts.js +2 -0
  24. package/dist/commands/doctor-pipeline.js +5 -2
  25. package/dist/commands/doctor.js +26 -4
  26. package/dist/commands/issue-directory.js +7 -0
  27. package/dist/commands/issue.js +23 -4
  28. package/dist/commands/local-arg-values.js +9 -0
  29. package/dist/commands/local-args-collector-setup.js +4 -2
  30. package/dist/commands/local-args-collector-work.js +9 -1
  31. package/dist/commands/local-help-commands.js +4 -1
  32. package/dist/commands/local-help.js +1 -1
  33. package/dist/commands/local.js +14 -0
  34. package/dist/commands/mail.js +20 -4
  35. package/dist/commands/memory-store.js +27 -10
  36. package/dist/commands/msg.js +25 -5
  37. package/dist/commands/public-root.js +1 -1
  38. package/dist/commands/read-cache.js +115 -0
  39. package/dist/commands/settings.js +17 -2
  40. package/dist/commands/tower-command.js +4 -0
  41. package/dist/cursors/raw-evidence-cursor.js +14 -0
  42. package/dist/tower-client.js +31 -3
  43. package/dist/upload-envelope-build.js +9 -0
  44. package/dist/upload-sync.js +10 -22
  45. package/dist/warm-tower-fetch.js +83 -0
  46. 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
+ }
@@ -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 { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
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 addIcal(command, door);
44
+ return runCalAddIcal(command, door);
46
45
  case "create":
47
46
  return create(command, door);
48
47
  case "share":
49
- return share(command, door);
48
+ return runCalShare(command, door);
50
49
  case "detach":
51
- return detach(command, door);
50
+ return runCalDetach(command, door);
52
51
  case "sync":
53
- return sync(command, door);
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 answer = await askAgentDoor(door, {
96
- path: `/api/cal/${path}?${windowQuery(command).toString()}`,
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
  }
@@ -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 answer = await askAgentDoor(door, {
78
- path: docsListPath(command),
79
- method: "GET",
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
- timeoutMs: READ_DEADLINE_MS,
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
- const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
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.command.lockWaitSeconds ?? 600) * 1000;
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
- return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
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.command.backfillBudgetSeconds ?? 900) * 1000;
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.command.lockWaitSeconds ?? 600, (deadline - Date.now()) / 1000),
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());
@@ -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 context = { command, io, deps };
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
- if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
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 doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
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 };
@@ -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 answer = await askAgentDoor(door, {
115
- path: `/api/work/issues${suffix ? `?${suffix}` : ""}`,
116
- method: "GET",
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
- timeoutMs: READ_DEADLINE_MS,
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.