@bli-cockpit/cli 0.2.148 → 0.2.150

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.
@@ -23,6 +23,7 @@ export async function openAgentDoor(commandName, command, io) {
23
23
  deviceToken: session.device_token,
24
24
  io,
25
25
  json: command.json,
26
+ homeDir: command.homeDir,
26
27
  };
27
28
  }
28
29
  export async function askAgentDoor(door, options) {
@@ -35,6 +36,8 @@ export async function askAgentDoor(door, options) {
35
36
  label: options.label,
36
37
  timeoutMs: options.timeoutMs,
37
38
  ...(options.body === undefined ? {} : { body: options.body }),
39
+ ...(options.warmRoute === undefined ? {} : { warmRoute: options.warmRoute }),
40
+ ...(door.homeDir === undefined ? {} : { homeDir: door.homeDir }),
38
41
  log: (line) => writeLine(door.io.stderr, line),
39
42
  });
40
43
  if (!result.ok) {
@@ -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;
@@ -1,7 +1,5 @@
1
1
  import os from "node:os";
2
- import { readRootWorkScan, outsideWorkDirectories, readRootApprovals, rootsAddCommand, rootPathApi, sessionCoverageLine } from "../root-work.js";
3
- import { writeJsonFile } from "../local-state-files.js";
4
- import { containsPath } from "../root-normalization.js";
2
+ import { readRootWorkScan, outsideWorkDirectories, rootsAddCommand, sessionCoverageLine } from "../root-work.js";
5
3
  import { scanWorkDirectories } from "./local-discovery.js";
6
4
  import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
7
5
  import { isInteractiveDoctorFix } from "./doctor-report.js";
@@ -157,55 +155,36 @@ function shellQuote(value) {
157
155
  return `'${value.replace(/'/gu, "'\\''")}'`;
158
156
  }
159
157
  /**
160
- * The last scan names work missed by the saved boundary. Diagnosis never
161
- * grants consent — and since BLI-10734 there is nothing left to consent TO
162
- * for the session report itself, only for raw evidence: every session this
163
- * scan saw now reaches the server (`sessionCoverageLine`), labelled
164
- * `root_scope: "outside"` when its directory sits outside every approved
165
- * root. Widening the boundary still matters for the redacted transcript
166
- * BYTES, which stay root-gated, so this row keeps flagging the gap and
167
- * offering `roots add` — it is just no longer offering to rescue a session
168
- * that would otherwise vanish.
158
+ * The last scan names where this machine's work happens. Diagnosis never
159
+ * grants consent, and since BLI-10734 there is nothing left to consent TO for
160
+ * the session report itself: every session this scan saw already reached the
161
+ * server (`sessionCoverageLine`), labelled `root_scope: "outside"` when its
162
+ * directory sits outside every approved root. BLI-10740 finishes that ruling
163
+ * on the doctor row itself — approved roots are a LABEL on the upload, never
164
+ * a gate on it, so a session sitting outside one is not a defect doctor found
165
+ * and cannot repair. This row is therefore always informational (`ok`): it
166
+ * reports how many of this scan's sessions were inside vs. outside, never
167
+ * asks for approval, and attempts no fix. Widening the boundary still buys
168
+ * the redacted transcript BYTES for those outside directories (those stay
169
+ * root-gated), so the message still names `cockpit roots add` as something an
170
+ * operator can choose to do, never something doctor is stuck waiting on. The
171
+ * row only turns red the ordinary way every other doctor check does: the scan
172
+ * itself throwing (`work_directory_scan_incomplete` from unreadable session
173
+ * folders), which `runDoctorWithDeps`'s existing `check_threw` handling
174
+ * already turns into a `fail` row — a real collection problem, not a roots
175
+ * problem.
169
176
  */
170
177
  export async function readRootsCoverWork(context) {
171
178
  const homeDir = context.command.homeDir ?? os.homedir();
172
179
  const paths = getCollectorRuntimePaths(homeDir);
173
180
  const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir, false);
174
181
  const outside = outsideWorkDirectories(scan, (await readLocalCollectorConfig(paths))?.default_repo_paths ?? []);
175
- const count = outside.reduce((sum, entry) => sum + entry.count, 0);
176
- // ticket 10727 (ticket 4382 measured) then BLI-10734 (roots are labels, not
177
- // gates): the coverage sentence a person asked doctor for, appended rather
178
- // than replacing the existing per-directory breakdown below. Since
179
- // BLI-10734, widening a root no longer rescues a session from oblivion —
180
- // its metadata already reached the server either way — it buys the
181
- // redacted transcript content instead, so the "needs fix" branch says so.
182
- if (!count)
183
- return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots. ${sessionCoverageLine(scan, count)}`);
182
+ const outsideCount = outside.reduce((sum, entry) => sum + entry.count, 0);
183
+ const insideCount = scan.observed - outsideCount;
184
+ if (!outsideCount)
185
+ return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots. ${sessionCoverageLine(scan, outsideCount)}`);
184
186
  return {
185
- ...needsFix("roots-cover-work", "work_outside_roots", `${count} of ${scan.observed} sessions have working directories outside every approved root — their redacted transcript content stays unuploaded until the root widens, though their metadata already reaches the server with an outside label (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, count)}`),
187
+ ...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside approved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Their redacted transcript content stays unuploaded until a root widens (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
186
188
  nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
187
189
  };
188
- }
189
- export async function fixRootsCoverWork(context) {
190
- const homeDir = context.command.homeDir ?? os.homedir();
191
- const paths = getCollectorRuntimePaths(homeDir);
192
- const config = await readLocalCollectorConfig(paths);
193
- if (!config)
194
- return readRootsCoverWork(context);
195
- const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir);
196
- const approvals = await readRootApprovals(paths);
197
- const outside = outsideWorkDirectories(scan, config.default_repo_paths);
198
- const additions = outside.filter(entry => approvals.some(root => containsPath(root, entry.directory, rootPathApi())));
199
- if (additions.length) {
200
- // Restore the precise approved directories, preserving every other config option.
201
- const roots = normalizeCollectionRoots([...config.default_repo_paths, ...additions.map(entry => entry.directory)], rootPathApi());
202
- await writeJsonFile(paths.config_file, { ...config, default_repo_paths: roots });
203
- console.error("[doctor] roots-cover-work repaired", JSON.stringify({ added: additions.length }));
204
- await scanWorkDirectories(homeDir);
205
- }
206
- else {
207
- console.error("[doctor] roots-cover-work needs approval", JSON.stringify({ outside: outside.length, added: 0 }));
208
- }
209
- const checked = await readRootsCoverWork(context);
210
- return { ...checked, fixed: additions.length > 0 && checked.status === "ok" };
211
190
  }
@@ -2,7 +2,7 @@ import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
2
  import { isSemverBelow } from "../scheduled-self-update.js";
3
3
  import { describeError } from "../health-detail.js";
4
4
  import { withStage } from "../crash-guard.js";
5
- import { checkSingleInstallState, readRootsCoverWork, fixRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
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
8
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
@@ -119,7 +119,10 @@ function doctorInvariants() {
119
119
  fix: fixRootState,
120
120
  requiresInteractiveFix: true,
121
121
  },
122
- { id: "roots-cover-work", check: context => context.deps.readRootsCoverWork(context), fix: context => context.deps.fixRootsCoverWork(context) },
122
+ // BLI-10740: roots are a label on the upload, never a gate — this row has
123
+ // no fix because a session outside an approved root is not a defect
124
+ // doctor can repair; it names the counts and moves on.
125
+ { id: "roots-cover-work", check: context => context.deps.readRootsCoverWork(context) },
123
126
  // Deliberately has NO fix: uninstalling software the operator did not ask
124
127
  // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
125
128
  // other install and the exact command; the person decides.
@@ -194,7 +197,6 @@ function defaultDoctorDeps(hooks) {
194
197
  runLogin: (context) => hooks.runLogin(context.command, context.io),
195
198
  readRoots: readRootState,
196
199
  readRootsCoverWork,
197
- fixRootsCoverWork,
198
200
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
199
201
  checkSingleInstall: checkSingleInstallState,
200
202
  checkAutostart: checkAutostartState,
@@ -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.
@@ -91,7 +91,7 @@ export function localSubcommandHelp(command) {
91
91
  "",
92
92
  "Diagnose, repair, and verify this machine.",
93
93
  "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
94
- "roots-cover-work names sessions outside saved roots and gives cockpit roots add commands. Previously approved directories are restored automatically.",
94
+ "roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for widening a root's redacted transcript content. It never needs a fix.",
95
95
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
96
96
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
97
97
  "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
@@ -65,6 +65,20 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
65
65
  argv = argv.filter((token) => token !== "--quiet");
66
66
  process.env.COCKPIT_QUIET = "1";
67
67
  }
68
+ // BLI-10746: `--fresh`/`--allow-stale` are global toggles for the
69
+ // stale-while-revalidate read cache, same reason and same shape as
70
+ // `--quiet` above — stripped here so none of the seven cached read
71
+ // commands' own `allowedFlags` needs to learn them, and mirrored onto
72
+ // env vars `read-cache.ts` reads. One `cockpit` process makes at most one
73
+ // cacheable request, so a process-wide toggle is exactly a per-request one.
74
+ if (argv.includes("--fresh")) {
75
+ argv = argv.filter((token) => token !== "--fresh");
76
+ process.env.COCKPIT_READ_FRESH = "1";
77
+ }
78
+ if (argv.includes("--allow-stale")) {
79
+ argv = argv.filter((token) => token !== "--allow-stale");
80
+ process.env.COCKPIT_READ_ALLOW_STALE = "1";
81
+ }
68
82
  if (isLocalHelpRequest(argv)) {
69
83
  writeLine(io.stdout, localCommandHelp(argv[0]));
70
84
  return 0;
@@ -24,6 +24,7 @@ import { writeFile } from "node:fs/promises";
24
24
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
25
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
26
  import { readNoteFile } from "./notes-file.js";
27
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
27
28
  import { towerRequest } from "../tower-client.js";
28
29
  const TAG = "[mail cli]";
29
30
  const READ_DEADLINE_MS = 30_000;
@@ -151,17 +152,32 @@ async function inbox(command, door) {
151
152
  if (command.label)
152
153
  query.set("label", command.label);
153
154
  const suffix = query.toString();
154
- const answer = await askAgentDoor(door, {
155
- path: `/api/mail/inbox${suffix ? `?${suffix}` : ""}`,
156
- method: "GET",
155
+ const inboxPath = `/api/mail/inbox${suffix ? `?${suffix}` : ""}`;
156
+ const answer = await readThroughCache({
157
+ io: door.io,
158
+ homeDir: door.homeDir,
159
+ deviceToken: door.deviceToken,
160
+ surface: "mail",
157
161
  label: "mail inbox",
158
- timeoutMs: READ_DEADLINE_MS,
162
+ path: inboxPath,
163
+ json: door.json,
164
+ ask: () => askAgentDoor(door, {
165
+ path: inboxPath,
166
+ method: "GET",
167
+ label: "mail inbox",
168
+ timeoutMs: READ_DEADLINE_MS,
169
+ // BLI-10746: route through the resident memory daemon's warm connection.
170
+ warmRoute: true,
171
+ }),
159
172
  });
160
173
  if (!answer.ok)
161
174
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
162
175
  const body = answer.body;
163
176
  if (door.json)
164
177
  return emitAgentDoor(door, { ok: true, ...body });
178
+ if (answer.fromCache) {
179
+ writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
180
+ }
165
181
  const messages = body.messages ?? [];
166
182
  if (messages.length === 0) {
167
183
  writeLine(door.io.stdout, (body.accounts ?? []).length === 0 ? "No mailboxes attached yet." : "No mail matches that.");
@@ -46,6 +46,7 @@ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.
46
46
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
47
47
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
48
48
  import { runMemoryConsolidation } from "./memory-consolidation.js";
49
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
49
50
  import { runMemoryRequeue } from "./memory-requeue.js";
50
51
  import { readNoteFile } from "./notes-file.js";
51
52
  export { runMemoryVerbList } from "./memory-verb-list.js";
@@ -142,17 +143,30 @@ function containerTagsFor(command) {
142
143
  }
143
144
  async function searchMemory(command, door) {
144
145
  const containerTags = containerTagsFor(command);
145
- const answer = await askAgentDoor(door, {
146
- path: "/api/memory/search",
147
- method: "POST",
146
+ const requestBody = {
147
+ query: command.query,
148
+ containerTag: containerTags,
149
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
150
+ includeProfile: command.includeRecent !== false,
151
+ };
152
+ const answer = await readThroughCache({
153
+ io: door.io,
154
+ homeDir: door.homeDir,
155
+ deviceToken: door.deviceToken,
156
+ surface: "memory",
148
157
  label: "memory search",
149
- timeoutMs: SEARCH_DEADLINE_MS,
150
- body: {
151
- query: command.query,
152
- containerTag: containerTags,
153
- ...(command.limit === undefined ? {} : { limit: command.limit }),
154
- includeProfile: command.includeRecent !== false,
155
- },
158
+ path: "/api/memory/search",
159
+ requestBody,
160
+ json: door.json,
161
+ ask: () => askAgentDoor(door, {
162
+ path: "/api/memory/search",
163
+ method: "POST",
164
+ label: "memory search",
165
+ timeoutMs: SEARCH_DEADLINE_MS,
166
+ // BLI-10746: the one POST this repo treats as a warm-routable read.
167
+ warmRoute: true,
168
+ body: requestBody,
169
+ }),
156
170
  });
157
171
  if (!answer.ok)
158
172
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
@@ -170,6 +184,9 @@ async function searchMemory(command, door) {
170
184
  })}`);
171
185
  if (door.json)
172
186
  return emitAgentDoor(door, answer.body);
187
+ if (answer.fromCache) {
188
+ writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
189
+ }
173
190
  if (results.length === 0 && body.degraded) {
174
191
  // BLI-10448: an empty answer from a search that lost a channel is NOT the
175
192
  // record being silent. `semantic_timed_out` is the store answering on
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
20
21
  const TAG = "[msg cli]";
21
22
  const READ_DEADLINE_MS = 30_000;
22
23
  const WRITE_DEADLINE_MS = 30_000;
@@ -39,15 +40,31 @@ export async function runMsg(command, io) {
39
40
  }
40
41
  }
41
42
  async function fetchChannels(door) {
42
- const answer = await askAgentDoor(door, {
43
- path: "/api/msg/channels",
44
- method: "GET",
43
+ const answer = await readThroughCache({
44
+ io: door.io,
45
+ homeDir: door.homeDir,
46
+ deviceToken: door.deviceToken,
47
+ surface: "msg",
45
48
  label: "msg channels",
46
- timeoutMs: READ_DEADLINE_MS,
49
+ path: "/api/msg/channels",
50
+ json: door.json,
51
+ ask: () => askAgentDoor(door, {
52
+ path: "/api/msg/channels",
53
+ method: "GET",
54
+ label: "msg channels",
55
+ timeoutMs: READ_DEADLINE_MS,
56
+ // BLI-10746: route through the resident memory daemon's warm connection.
57
+ warmRoute: true,
58
+ }),
47
59
  });
48
60
  if (!answer.ok)
49
61
  return { ok: false, reason: answer.reason, detail: answer.detail };
50
- return { ok: true, channels: answer.body.channels ?? [] };
62
+ return {
63
+ ok: true,
64
+ channels: answer.body.channels ?? [],
65
+ fromCache: answer.fromCache,
66
+ ageMs: answer.ageMs,
67
+ };
51
68
  }
52
69
  async function listChannels(door) {
53
70
  const fetched = await fetchChannels(door);
@@ -55,6 +72,9 @@ async function listChannels(door) {
55
72
  return failAgentDoor(door, TAG, fetched.reason, fetched.detail);
56
73
  if (door.json)
57
74
  return emitAgentDoor(door, { ok: true, channels: fetched.channels });
75
+ if (fetched.fromCache) {
76
+ writeLine(door.io.stderr, `(${cacheAgeLabel(fetched.ageMs ?? 0)}, refreshing in the background)`);
77
+ }
58
78
  if (fetched.channels.length === 0) {
59
79
  writeLine(door.io.stdout, "No channels.");
60
80
  return 0;
@@ -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.148");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.150");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * STALE-WHILE-REVALIDATE FOR THE SEVEN READ COMMANDS BLI-10746 MEASURED
3
+ * (`work list`, `docs list`, `cal today`, `mail inbox`, `msg channels`,
4
+ * `settings personal`, `memory search`).
5
+ *
6
+ * Text mode: a cache hit prints INSTANTLY (its age travels with it), then a
7
+ * background refresh is spawned so the next call is live — never blocking
8
+ * this process on a network round trip it already has a recent answer for.
9
+ * `--json` (an agent, not a person) trusts cache only under
10
+ * `JSON_FRESH_BOUND_MS`, unless `--allow-stale` says otherwise; older than
11
+ * that runs the live request inline, same as a miss. `--fresh` skips the
12
+ * read entirely — both flags are process-wide (BLI-10450's `--quiet`
13
+ * pattern), stripped from argv and mirrored onto `COCKPIT_READ_FRESH` /
14
+ * `COCKPIT_READ_ALLOW_STALE` by `local.ts`, so none of these seven parsers'
15
+ * own `allowedFlags` needed to learn a new flag.
16
+ *
17
+ * **The background refresh is a whole second `cockpit` process, detached.**
18
+ * Not a lighter internal-only command: it re-runs THIS SAME argv with
19
+ * `--fresh` appended, `stdio: "ignore"`, unref'd. That is deliberately the
20
+ * simplest correct thing — it reuses every bit of behaviour the live path
21
+ * already has (auth, rendering, the warm route) with zero drift risk, at
22
+ * the cost of a process this ticket's "abundant compute" call already
23
+ * accepts (`docs/architecture/repo-map.md`'s standing orders). The PARENT
24
+ * never waits on it, so the terminal returns control the instant the cached
25
+ * answer is printed — a detached child is the only shape that lets a write
26
+ * to disk keep happening after THIS process has already exited (an
27
+ * unref'd but still-open socket dies with the process; a separate process
28
+ * does not).
29
+ *
30
+ * **Any write invalidates.** `tower-client.ts`'s `towerRequest` calls
31
+ * `invalidateReadCacheSurface` (from `@bli-cockpit/telemetry-core`'s
32
+ * `./node-read-cache`, the same module this reads and writes through) on
33
+ * every successful non-GET response whose path names one of the seven
34
+ * surfaces — coarse (the whole surface, not just the one changed row) and
35
+ * correct: a stale row is never possible, only one avoidable extra request.
36
+ */
37
+ import { spawn } from "node:child_process";
38
+ import { readCacheEntryPath, readCacheKey, readReadCacheEntry, writeReadCacheEntryAtomic, } from "@bli-cockpit/telemetry-core/node-read-cache";
39
+ import { writeLine } from "./cli-io.js";
40
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
41
+ /** An agent's `--json` read trusts cache only this fresh, unless `--allow-stale`. */
42
+ export const JSON_FRESH_BOUND_MS = 30_000;
43
+ /** Never spawns a second refresh for an answer this recently written — avoids a pile-up under rapid repeated calls. */
44
+ export const MIN_REFRESH_INTERVAL_MS = 3_000;
45
+ /** `"cached 3s ago"` / `"cached 4m ago"` — the one phrasing every cached read command prints. */
46
+ export function cacheAgeLabel(ageMs) {
47
+ const seconds = Math.max(0, Math.round(ageMs / 1000));
48
+ if (seconds < 60)
49
+ return `cached ${seconds}s ago`;
50
+ const minutes = Math.round(seconds / 60);
51
+ return `cached ${minutes}m ago`;
52
+ }
53
+ export async function readThroughCache(options) {
54
+ const log = (line) => writeLine(options.io.stderr, line);
55
+ const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
56
+ const key = readCacheKey(options.label, options.path, options.requestBody);
57
+ const entryPath = readCacheEntryPath(stateDir, options.deviceToken, options.surface, key);
58
+ const fresh = options.io.env["COCKPIT_READ_FRESH"] === "1";
59
+ const allowStale = options.io.env["COCKPIT_READ_ALLOW_STALE"] === "1";
60
+ if (fresh) {
61
+ log(`[read-cache] bypassed ${JSON.stringify({ surface: options.surface, label: options.label, reason: "fresh_flag" })}`);
62
+ }
63
+ else {
64
+ const cached = readReadCacheEntry(entryPath);
65
+ if (cached) {
66
+ const ageMs = Date.now() - cached.storedAt;
67
+ const trusted = !options.json || ageMs < JSON_FRESH_BOUND_MS || allowStale;
68
+ if (trusted) {
69
+ log(`[read-cache] served ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs, json: options.json })}`);
70
+ if (ageMs > MIN_REFRESH_INTERVAL_MS) {
71
+ spawnBackgroundRefresh(options.io, log, options.spawnImpl);
72
+ }
73
+ else {
74
+ log(`[read-cache] refresh skipped ${JSON.stringify({ reason: "too_recent", age_ms: ageMs })}`);
75
+ }
76
+ return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs };
77
+ }
78
+ log(`[read-cache] too stale for --json ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs })}`);
79
+ }
80
+ else {
81
+ log(`[read-cache] miss ${JSON.stringify({ surface: options.surface, label: options.label })}`);
82
+ }
83
+ }
84
+ const live = await options.ask();
85
+ if (live.ok) {
86
+ writeReadCacheEntryAtomic(entryPath, { status: live.httpStatus, body: live.body });
87
+ log(`[read-cache] refreshed ${JSON.stringify({ surface: options.surface, label: options.label })}`);
88
+ }
89
+ return { ...live, fromCache: false, ageMs: null };
90
+ }
91
+ /**
92
+ * Re-runs THIS process's own argv with `--fresh` appended, detached and
93
+ * discarded. Never awaited, never throws — a refresh that could not be
94
+ * spawned just means the next call finds the same cache age it would have
95
+ * found anyway; it is never this call's failure to report.
96
+ */
97
+ export function spawnBackgroundRefresh(io, log, spawnImpl = spawn) {
98
+ try {
99
+ const args = process.argv
100
+ .slice(2)
101
+ .filter((token) => token !== "--fresh" && token !== "--allow-stale");
102
+ args.push("--fresh");
103
+ const child = spawnImpl(process.execPath, [process.argv[1] ?? "", ...args], {
104
+ detached: true,
105
+ stdio: "ignore",
106
+ windowsHide: true,
107
+ env: io.env,
108
+ });
109
+ child.unref();
110
+ log(`[read-cache] background refresh spawned ${JSON.stringify({ argv_len: args.length })}`);
111
+ }
112
+ catch (error) {
113
+ log(`[read-cache] background refresh failed to spawn ${JSON.stringify({ reason: error instanceof Error ? error.name : "unknown" })}`);
114
+ }
115
+ }
@@ -20,6 +20,7 @@
20
20
  * being written to stdout, stderr, or an argument vector.
21
21
  */
22
22
  import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
23
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
23
24
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
24
25
  import { showOverview } from "./settings-overview.js";
25
26
  import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, } from "./settings-render.js";
@@ -54,12 +55,26 @@ export async function runSettings(command, io) {
54
55
  }
55
56
  // ── personal ──────────────────────────────────────────────────────────
56
57
  async function showPersonal(command, tower, io) {
57
- const result = await callTower(tower, {
58
- path: "/api/settings/personal",
58
+ const result = await readThroughCache({
59
+ io,
60
+ homeDir: tower.homeDir,
61
+ deviceToken: tower.deviceToken,
62
+ surface: "settings",
59
63
  label: "settings personal",
64
+ path: "/api/settings/personal",
65
+ json: command.json,
66
+ ask: () => callTower(tower, {
67
+ path: "/api/settings/personal",
68
+ label: "settings personal",
69
+ // BLI-10746: route through the resident memory daemon's warm connection.
70
+ warmRoute: true,
71
+ }),
60
72
  });
61
73
  if (!result.ok)
62
74
  return writeCommandFailure(io, command.json, result);
75
+ if (result.fromCache && !command.json) {
76
+ writeLine(io.stderr, `(${cacheAgeLabel(result.ageMs ?? 0)}, refreshing in the background)`);
77
+ }
63
78
  return writeBody(io, command.json, result.body, () => renderPersonal(asRecord(result.body)));
64
79
  }
65
80
  async function setPersonal(command, tower, io) {
@@ -33,6 +33,8 @@ export async function openTower(commandName, command, io) {
33
33
  deviceToken: session.device_token,
34
34
  fetch: io.fetch,
35
35
  log: (line) => writeLine(io.stderr, line),
36
+ io,
37
+ homeDir: command.homeDir,
36
38
  };
37
39
  }
38
40
  export async function callTower(context, options) {
@@ -45,6 +47,8 @@ export async function callTower(context, options) {
45
47
  ...(options.body === undefined ? {} : { body: options.body }),
46
48
  timeoutMs: options.timeoutMs ?? REQUEST_DEADLINE_MS,
47
49
  label: options.label,
50
+ ...(options.warmRoute === undefined ? {} : { warmRoute: options.warmRoute }),
51
+ ...(context.homeDir === undefined ? {} : { homeDir: context.homeDir }),
48
52
  log: context.log,
49
53
  });
50
54
  if (result.ok)
@@ -23,9 +23,11 @@
23
23
  * `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
24
24
  * the upload path and the conversation path answer to the same rules.
25
25
  */
26
+ import { invalidateReadCacheSurface, readCacheSurfaceForPath, } from "@bli-cockpit/telemetry-core/node-read-cache";
26
27
  import { errorMessage } from "./commands/cli-io.js";
27
28
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "./local-state.js";
28
29
  import { normalizeDashboardUrl, readResponseJson, responseErrorMessage, } from "./upload-http.js";
30
+ import { isWarmRoutableRead, tryWarmTowerFetch } from "./warm-tower-fetch.js";
29
31
  const defaultLog = (line) => {
30
32
  console.error(line);
31
33
  };
@@ -77,6 +79,7 @@ export async function towerRequest(options) {
77
79
  const startedAt = Date.now();
78
80
  const url = `${normalizeDashboardUrl(options.dashboardUrl)}${options.path}`;
79
81
  const isForm = typeof FormData !== "undefined" && options.body instanceof FormData;
82
+ const method = options.method ?? "POST";
80
83
  const headers = {
81
84
  ...(options.headers ?? {}),
82
85
  authorization: `Bearer ${options.deviceToken}`,
@@ -85,16 +88,26 @@ export async function towerRequest(options) {
85
88
  // content-type by hand would strip it and the server would parse nothing.
86
89
  if (options.body !== undefined && !isForm)
87
90
  headers["content-type"] = "application/json";
91
+ const body = options.body === undefined || isForm ? undefined : JSON.stringify(options.body);
88
92
  const init = {
89
- method: options.method ?? "POST",
93
+ method,
90
94
  headers,
91
95
  ...(options.body === undefined
92
96
  ? {}
93
- : { body: (isForm ? options.body : JSON.stringify(options.body)) }),
97
+ : { body: (isForm ? options.body : body) }),
94
98
  ...(timeoutSignal(options.timeoutMs) ?? {}),
95
99
  };
96
100
  try {
97
- const response = await options.fetch(url, init);
101
+ // BLI-10746: a read (GET, or the one POST read this repo has —
102
+ // `memory search`) skips the cold TCP+TLS connect by asking the resident
103
+ // memory daemon to replay it on its own warm dispatcher. `isForm` never
104
+ // holds for a read this repo makes, but the check is here so a future
105
+ // read that DOES carry a form body never gets silently mis-proxied.
106
+ const warmEligible = options.warmRoute === true && !isForm && isWarmRoutableRead(method, options.label);
107
+ const warmResponse = warmEligible
108
+ ? await tryWarmTowerFetch(url, { method, headers, body }, log, { homeDir: options.homeDir })
109
+ : null;
110
+ const response = warmResponse ?? (await options.fetch(url, init));
98
111
  const respondedMs = Date.now() - startedAt;
99
112
  // The success branch always decided something (BLI-10450 kept the
100
113
  // "log the success branch too" rule intact) — it is just that under
@@ -111,6 +124,21 @@ export async function towerRequest(options) {
111
124
  elapsed_ms: respondedMs,
112
125
  })}`);
113
126
  }
127
+ // BLI-10746: "any write through the CLI or MCP invalidates the affected
128
+ // cache". A successful (2xx) write to a covered surface throws away
129
+ // every cached read for that surface — coarse, but a stale row is never
130
+ // possible this way, only one avoidable extra request.
131
+ if (method !== "GET" && response.ok) {
132
+ const surface = readCacheSurfaceForPath(options.path);
133
+ if (surface) {
134
+ const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
135
+ const invalidated = invalidateReadCacheSurface(stateDir, options.deviceToken, surface);
136
+ log(`[tower client] read cache invalidated ${JSON.stringify({
137
+ surface,
138
+ ...(invalidated.ok ? { removed: invalidated.removed } : { reason: invalidated.reason }),
139
+ })}`);
140
+ }
141
+ }
114
142
  return { ok: true, response, startedAt, respondedMs };
115
143
  }
116
144
  catch (error) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A CLI READ, ROUTED THROUGH THE RESIDENT MEMORY DAEMON'S WARM CONNECTION
3
+ * (BLI-10746).
4
+ *
5
+ * Every `cockpit` process today opens its own TCP+TLS connection to Tower —
6
+ * measured from Vietnam, about 420 ms of the ~1,700 ms `work list` takes is
7
+ * exactly that setup, paid fresh on every single command. `@bli-cockpit/
8
+ * memory-mcp`'s resident daemon already holds one connection open across
9
+ * turns for recall (BLI-10741); this module is "reuse that for every other
10
+ * read too" — a GET (or the one POST read this repo has, `memory search`)
11
+ * asks the daemon to replay it on its own dispatcher instead of opening a new
12
+ * socket.
13
+ *
14
+ * **Fallback is the whole safety argument.** A daemon that is absent, wedged,
15
+ * refuses, times out, or answers something this cannot parse means exactly
16
+ * one thing here: go the way this command always went. `isWarmRoutableRead`
17
+ * decides which requests are even eligible — writes NEVER take this path, so
18
+ * there is no daemon-retry-of-a-write hazard to reason about.
19
+ *
20
+ * **Auth never changes hands.** The caller's own `authorization` header
21
+ * travels inside `init.headers` exactly as `towerRequest` already built it;
22
+ * the daemon forwards it unread (see `@bli-cockpit/memory-mcp`'s
23
+ * `daemon/http-proxy.ts`). This module never reads the device token either.
24
+ */
25
+ import os from "node:os";
26
+ import { memoryDaemonEndpoint } from "@bli-cockpit/telemetry-core";
27
+ import { askMemoryDaemonHttp, } from "@bli-cockpit/telemetry-core/node-daemon-http-client";
28
+ /**
29
+ * The only POST this repo treats as a read for warm-routing purposes
30
+ * (`cockpit memory search`, `POST /api/memory/search`). Every other read is a
31
+ * GET; a caller names itself with the same `label` it already logs under.
32
+ */
33
+ const WARM_ROUTABLE_POST_LABELS = new Set(["memory search"]);
34
+ /** GET is always eligible; a small named allowlist covers the one read that is not. */
35
+ export function isWarmRoutableRead(method, label) {
36
+ return method === "GET" || WARM_ROUTABLE_POST_LABELS.has(label);
37
+ }
38
+ /** The whole local-socket-plus-daemon's-own-fetch budget. Comfortably under any `READ_DEADLINE_MS` a command sets. */
39
+ export const WARM_FETCH_DEADLINE_MS = 10_000;
40
+ /**
41
+ * Tries the daemon; returns `null` on ANY reason it could not, so the caller
42
+ * falls back to a direct request. Never throws.
43
+ */
44
+ export async function tryWarmTowerFetch(url, init, log, deps = {}) {
45
+ const endpoint = memoryDaemonEndpoint({
46
+ homeDir: deps.homeDir ?? os.homedir(),
47
+ ...(deps.platform ? { platform: deps.platform } : {}),
48
+ username: deps.username ?? safeUsername(),
49
+ });
50
+ if (endpoint.kind === "unix" && endpoint.addressTooLong) {
51
+ log(`[tower client] warm route skipped ${JSON.stringify({ reason: "socket_path_too_long" })}`);
52
+ return null;
53
+ }
54
+ const result = await askMemoryDaemonHttp({
55
+ address: endpoint.address,
56
+ deadlineMs: deps.deadlineMs ?? WARM_FETCH_DEADLINE_MS,
57
+ ...(deps.connectImpl ? { connectImpl: deps.connectImpl } : {}),
58
+ ...(deps.now ? { now: deps.now } : {}),
59
+ request: {
60
+ method: init.method,
61
+ url,
62
+ headers: init.headers,
63
+ ...(init.body === undefined ? {} : { body: init.body }),
64
+ },
65
+ });
66
+ if (!result.ok) {
67
+ log(`[tower client] warm route missed ${JSON.stringify({ reason: result.reason, elapsed_ms: result.elapsedMs })}`);
68
+ return null;
69
+ }
70
+ log(`[tower client] warm route used ${JSON.stringify({ elapsed_ms: result.elapsedMs })}`);
71
+ return new Response(result.answer.body, {
72
+ status: result.answer.status,
73
+ headers: result.answer.headers,
74
+ });
75
+ }
76
+ function safeUsername() {
77
+ try {
78
+ return os.userInfo().username;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.148",
3
+ "version": "0.2.150",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
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-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.37",
31
- "@bli-cockpit/mcp": "0.1.71",
32
- "@bli-cockpit/telemetry-core": "0.1.49"
30
+ "@bli-cockpit/memory-mcp": "0.1.39",
31
+ "@bli-cockpit/mcp": "0.1.73",
32
+ "@bli-cockpit/telemetry-core": "0.1.51"
33
33
  }
34
34
  }