@bli-cockpit/cli 0.2.149 → 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.
- package/dist/commands/agent-door.js +3 -0
- package/dist/commands/cal-calendars-write.js +107 -0
- package/dist/commands/cal.js +29 -110
- package/dist/commands/docs.js +20 -4
- package/dist/commands/issue-directory.js +7 -0
- package/dist/commands/issue.js +23 -4
- package/dist/commands/local.js +14 -0
- package/dist/commands/mail.js +20 -4
- package/dist/commands/memory-store.js +27 -10
- package/dist/commands/msg.js +25 -5
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/read-cache.js +115 -0
- package/dist/commands/settings.js +17 -2
- package/dist/commands/tower-command.js +4 -0
- package/dist/tower-client.js +31 -3
- package/dist/warm-tower-fetch.js +83 -0
- package/package.json +4 -4
|
@@ -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
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -21,15 +21,14 @@
|
|
|
21
21
|
* computed in UTC and "today" would end at 5pm in Vancouver.
|
|
22
22
|
*/
|
|
23
23
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
24
|
+
import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
|
|
24
25
|
import { runCalDelete } from "./cal-delete.js";
|
|
25
26
|
import { runCalSyncStatus } from "./cal-sync-status.js";
|
|
26
|
-
import {
|
|
27
|
+
import { writeLine } from "./cli-io.js";
|
|
28
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
27
29
|
const TAG = "[cal cli]";
|
|
28
30
|
const READ_DEADLINE_MS = 30_000;
|
|
29
31
|
const WRITE_DEADLINE_MS = 60_000;
|
|
30
|
-
/** A first read of a crowded calendar can take a while. */
|
|
31
|
-
const SYNC_DEADLINE_MS = 120_000;
|
|
32
|
-
const URL_MAX_CHARS = 2_000;
|
|
33
32
|
export async function runCal(command, io) {
|
|
34
33
|
const door = await openAgentDoor("cal", command, io);
|
|
35
34
|
switch (command.action) {
|
|
@@ -42,15 +41,15 @@ export async function runCal(command, io) {
|
|
|
42
41
|
case "calendars":
|
|
43
42
|
return listCalendars(door);
|
|
44
43
|
case "add-ical":
|
|
45
|
-
return
|
|
44
|
+
return runCalAddIcal(command, door);
|
|
46
45
|
case "create":
|
|
47
46
|
return create(command, door);
|
|
48
47
|
case "share":
|
|
49
|
-
return
|
|
48
|
+
return runCalShare(command, door);
|
|
50
49
|
case "detach":
|
|
51
|
-
return
|
|
50
|
+
return runCalDetach(command, door);
|
|
52
51
|
case "sync":
|
|
53
|
-
return
|
|
52
|
+
return runCalSync(command, door);
|
|
54
53
|
case "sync-status":
|
|
55
54
|
return runCalSyncStatus(command, door);
|
|
56
55
|
case "delete":
|
|
@@ -92,17 +91,37 @@ async function window(command, door) {
|
|
|
92
91
|
// `--from`/`--to` beat a named window, on any of the three verbs: somebody
|
|
93
92
|
// who named two dates meant them.
|
|
94
93
|
const path = command.from && command.to ? "events" : command.action;
|
|
95
|
-
const
|
|
96
|
-
|
|
94
|
+
const apiPath = `/api/cal/${path}?${windowQuery(command).toString()}`;
|
|
95
|
+
// BLI-10746: `cal today` is the one read this ticket measured; the warm
|
|
96
|
+
// route and the cache both apply to it (and only it).
|
|
97
|
+
const cached = command.action === "today";
|
|
98
|
+
const ask = () => askAgentDoor(door, {
|
|
99
|
+
path: apiPath,
|
|
97
100
|
method: "GET",
|
|
98
101
|
label: `cal ${command.action}`,
|
|
99
102
|
timeoutMs: READ_DEADLINE_MS,
|
|
103
|
+
warmRoute: cached,
|
|
100
104
|
});
|
|
105
|
+
const answer = cached
|
|
106
|
+
? await readThroughCache({
|
|
107
|
+
io: door.io,
|
|
108
|
+
homeDir: door.homeDir,
|
|
109
|
+
deviceToken: door.deviceToken,
|
|
110
|
+
surface: "cal",
|
|
111
|
+
label: "cal today",
|
|
112
|
+
path: apiPath,
|
|
113
|
+
json: door.json,
|
|
114
|
+
ask,
|
|
115
|
+
})
|
|
116
|
+
: { ...(await ask()), fromCache: false, ageMs: null };
|
|
101
117
|
if (!answer.ok)
|
|
102
118
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
103
119
|
const body = answer.body;
|
|
104
120
|
if (door.json)
|
|
105
121
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
122
|
+
if (answer.fromCache) {
|
|
123
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
124
|
+
}
|
|
106
125
|
const zone = body.window?.time_zone ?? localTimeZone();
|
|
107
126
|
const events = body.events ?? [];
|
|
108
127
|
if (events.length === 0) {
|
|
@@ -246,46 +265,6 @@ async function listCalendars(door) {
|
|
|
246
265
|
writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
|
|
247
266
|
return 0;
|
|
248
267
|
}
|
|
249
|
-
async function addIcal(command, door) {
|
|
250
|
-
// The address comes from stdin and nowhere else. An interactive terminal
|
|
251
|
-
// with nothing piped in is told how, rather than left waiting on a prompt
|
|
252
|
-
// that would echo a permanent credential into the scrollback.
|
|
253
|
-
if (isInteractiveStdin(door.io)) {
|
|
254
|
-
return failAgentDoor(door, TAG, "address_required_on_stdin", 'The calendar\'s secret address is read from stdin, never from a flag. Try: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"');
|
|
255
|
-
}
|
|
256
|
-
let icalUrl;
|
|
257
|
-
try {
|
|
258
|
-
icalUrl = (await readPipedText(door.io.stdin, {
|
|
259
|
-
maxChars: URL_MAX_CHARS,
|
|
260
|
-
overflowMessage: "That is longer than any calendar address; nothing was attached.",
|
|
261
|
-
})).trim();
|
|
262
|
-
}
|
|
263
|
-
catch (error) {
|
|
264
|
-
return failAgentDoor(door, TAG, "address_unreadable", error instanceof Error ? error.message : String(error));
|
|
265
|
-
}
|
|
266
|
-
if (icalUrl === "") {
|
|
267
|
-
return failAgentDoor(door, TAG, "address_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
|
|
268
|
-
}
|
|
269
|
-
const answer = await askAgentDoor(door, {
|
|
270
|
-
path: "/api/cal/calendars",
|
|
271
|
-
method: "POST",
|
|
272
|
-
label: "cal add-ical",
|
|
273
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
274
|
-
body: {
|
|
275
|
-
ical_url: icalUrl,
|
|
276
|
-
name: command.name ?? null,
|
|
277
|
-
...(command.orgVisible ? { org_visible: true } : {}),
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
if (!answer.ok)
|
|
281
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
282
|
-
const calendar = answer.body.calendar;
|
|
283
|
-
if (door.json)
|
|
284
|
-
return emitAgentDoor(door, { ok: true, calendar });
|
|
285
|
-
writeLine(door.io.stdout, `Attached (${calendar?.id ?? "no id returned"}).`);
|
|
286
|
-
writeLine(door.io.stdout, `Nothing is read until a sync runs: \`cockpit cal sync ${calendar?.id ?? "<id>"}\`, or wait for the cron.`);
|
|
287
|
-
return 0;
|
|
288
|
-
}
|
|
289
268
|
async function create(command, door) {
|
|
290
269
|
const answer = await askAgentDoor(door, {
|
|
291
270
|
path: "/api/cal/events",
|
|
@@ -328,64 +307,4 @@ async function create(command, door) {
|
|
|
328
307
|
writeLine(door.io.stdout, `Invitations went to ${command.attendees.join(", ")} from Google.`);
|
|
329
308
|
}
|
|
330
309
|
return 0;
|
|
331
|
-
}
|
|
332
|
-
async function share(command, door) {
|
|
333
|
-
const orgVisible = command.orgVisible && !command.makePrivate;
|
|
334
|
-
const answer = await askAgentDoor(door, {
|
|
335
|
-
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
336
|
-
method: "PATCH",
|
|
337
|
-
label: "cal share",
|
|
338
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
339
|
-
body: { org_visible: orgVisible },
|
|
340
|
-
});
|
|
341
|
-
if (!answer.ok)
|
|
342
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
343
|
-
const calendar = answer.body.calendar;
|
|
344
|
-
if (door.json)
|
|
345
|
-
return emitAgentDoor(door, { ok: true, calendar });
|
|
346
|
-
writeLine(door.io.stdout, orgVisible
|
|
347
|
-
? `"${calendar?.name ?? command.subjectId}" is now readable by everyone at BLI. Nobody but you can change it.`
|
|
348
|
-
: `"${calendar?.name ?? command.subjectId}" is private again — only you can read it.`);
|
|
349
|
-
return 0;
|
|
350
|
-
}
|
|
351
|
-
async function detach(command, door) {
|
|
352
|
-
const answer = await askAgentDoor(door, {
|
|
353
|
-
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
354
|
-
method: "DELETE",
|
|
355
|
-
label: "cal detach",
|
|
356
|
-
timeoutMs: WRITE_DEADLINE_MS,
|
|
357
|
-
});
|
|
358
|
-
if (!answer.ok)
|
|
359
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
360
|
-
if (door.json)
|
|
361
|
-
return emitAgentDoor(door, { ok: true, detached: command.subjectId });
|
|
362
|
-
writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored events went with it; the calendar itself is untouched at Google.`);
|
|
363
|
-
return 0;
|
|
364
|
-
}
|
|
365
|
-
async function sync(command, door) {
|
|
366
|
-
const answer = await askAgentDoor(door, {
|
|
367
|
-
path: `/api/cal/calendars/${encodeURIComponent(command.subjectId ?? "")}/sync${command.full ? "?full=1" : ""}`,
|
|
368
|
-
method: "POST",
|
|
369
|
-
label: "cal sync",
|
|
370
|
-
timeoutMs: SYNC_DEADLINE_MS,
|
|
371
|
-
});
|
|
372
|
-
if (!answer.ok)
|
|
373
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
374
|
-
const run = answer.body.run;
|
|
375
|
-
if (door.json)
|
|
376
|
-
return emitAgentDoor(door, { ok: true, run });
|
|
377
|
-
if (!run) {
|
|
378
|
-
writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
|
|
379
|
-
return 0;
|
|
380
|
-
}
|
|
381
|
-
if (run.reason === "ok") {
|
|
382
|
-
writeLine(door.io.stdout, run.detail === "feed_unchanged"
|
|
383
|
-
? "Nothing has changed on that calendar since the last read."
|
|
384
|
-
: `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`);
|
|
385
|
-
return 0;
|
|
386
|
-
}
|
|
387
|
-
// A failure names itself; the exit code says it failed.
|
|
388
|
-
writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
|
|
389
|
-
writeLine(door.io.stdout, `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
|
|
390
|
-
return 1;
|
|
391
310
|
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -32,6 +32,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
|
32
32
|
import { readDoc } from "./docs-read.js";
|
|
33
33
|
import { linkDoc, syncStatusDocs } from "./docs-notion.js";
|
|
34
34
|
import { readNoteFile } from "./notes-file.js";
|
|
35
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
35
36
|
const TAG = "[docs cli]";
|
|
36
37
|
const READ_DEADLINE_MS = 30_000;
|
|
37
38
|
const WRITE_DEADLINE_MS = 60_000;
|
|
@@ -74,17 +75,32 @@ export function docsListPath(command) {
|
|
|
74
75
|
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
75
76
|
}
|
|
76
77
|
async function listDocs(command, door) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
const listPath = docsListPath(command);
|
|
79
|
+
const answer = await readThroughCache({
|
|
80
|
+
io: door.io,
|
|
81
|
+
homeDir: door.homeDir,
|
|
82
|
+
deviceToken: door.deviceToken,
|
|
83
|
+
surface: "docs",
|
|
80
84
|
label: "docs list",
|
|
81
|
-
|
|
85
|
+
path: listPath,
|
|
86
|
+
json: door.json,
|
|
87
|
+
ask: () => askAgentDoor(door, {
|
|
88
|
+
path: listPath,
|
|
89
|
+
method: "GET",
|
|
90
|
+
label: "docs list",
|
|
91
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
92
|
+
// BLI-10746: route through the resident memory daemon's warm connection.
|
|
93
|
+
warmRoute: true,
|
|
94
|
+
}),
|
|
82
95
|
});
|
|
83
96
|
if (!answer.ok)
|
|
84
97
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
85
98
|
const documents = (answer.body.documents ?? []);
|
|
86
99
|
if (door.json)
|
|
87
100
|
return emitAgentDoor(door, { ok: true, documents });
|
|
101
|
+
if (answer.fromCache) {
|
|
102
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
103
|
+
}
|
|
88
104
|
if (documents.length === 0) {
|
|
89
105
|
writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
|
|
90
106
|
return 0;
|
|
@@ -22,6 +22,13 @@ async function fetchTeamDirectory(door) {
|
|
|
22
22
|
method: "GET",
|
|
23
23
|
label: "issue assignee directory",
|
|
24
24
|
timeoutMs: TEAM_DIRECTORY_TIMEOUT_MS,
|
|
25
|
+
// BLI-10746: this runs IN PARALLEL with `work list`'s own issues fetch
|
|
26
|
+
// (BLI-10579) — measured live, an un-warmed directory call was the
|
|
27
|
+
// reason `work list`'s end-to-end time did not show the daemon's own
|
|
28
|
+
// ~300ms handshake saving (its own fetch dropped from 749ms cold to
|
|
29
|
+
// ~420-500ms warm; the command's total didn't, because this parallel
|
|
30
|
+
// call was still paying a cold connection every time).
|
|
31
|
+
warmRoute: true,
|
|
25
32
|
});
|
|
26
33
|
if (!answer.ok)
|
|
27
34
|
return { ok: false, people: [], reason: answer.reason };
|
package/dist/commands/issue.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
29
29
|
import { writeLine } from "./cli-io.js";
|
|
30
30
|
import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
31
|
+
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
31
32
|
import { showIssue } from "./issue-show.js";
|
|
32
33
|
import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
|
|
33
34
|
import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
|
|
@@ -111,11 +112,26 @@ async function listIssues(command, door) {
|
|
|
111
112
|
// instead of waiting for that answer first (measured ~580ms of the ~580ms
|
|
112
113
|
// `work list` p50 was exactly this serial second call).
|
|
113
114
|
const directoryFetch = beginAssigneeDirectoryFetch(door);
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const listPath = `/api/work/issues${suffix ? `?${suffix}` : ""}`;
|
|
116
|
+
const answer = await readThroughCache({
|
|
117
|
+
io: door.io,
|
|
118
|
+
homeDir: door.homeDir,
|
|
119
|
+
deviceToken: door.deviceToken,
|
|
120
|
+
surface: "work",
|
|
117
121
|
label: "issue list",
|
|
118
|
-
|
|
122
|
+
path: listPath,
|
|
123
|
+
json: door.json,
|
|
124
|
+
ask: () => askAgentDoor(door, {
|
|
125
|
+
path: listPath,
|
|
126
|
+
method: "GET",
|
|
127
|
+
label: "issue list",
|
|
128
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
129
|
+
// BLI-10746: `work list` was the slowest of the six reads measured
|
|
130
|
+
// from Vietnam (1,682 ms p50) — route it through the resident memory
|
|
131
|
+
// daemon's warm connection so this command skips a fresh TCP+TLS
|
|
132
|
+
// handshake.
|
|
133
|
+
warmRoute: true,
|
|
134
|
+
}),
|
|
119
135
|
});
|
|
120
136
|
if (!answer.ok) {
|
|
121
137
|
// Not awaited for its own sake — `finishAssigneeDirectory` always awaits
|
|
@@ -123,6 +139,9 @@ async function listIssues(command, door) {
|
|
|
123
139
|
await finishAssigneeDirectory(door, directoryFetch, []);
|
|
124
140
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
125
141
|
}
|
|
142
|
+
if (answer.fromCache && !door.json) {
|
|
143
|
+
writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
|
|
144
|
+
}
|
|
126
145
|
const body = answer.body;
|
|
127
146
|
const issues = body.issues ?? [];
|
|
128
147
|
// BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
|
package/dist/commands/local.js
CHANGED
|
@@ -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;
|
package/dist/commands/mail.js
CHANGED
|
@@ -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
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
package/dist/commands/msg.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
58
|
-
|
|
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)
|
package/dist/tower-client.js
CHANGED
|
@@ -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
|
|
93
|
+
method,
|
|
90
94
|
headers,
|
|
91
95
|
...(options.body === undefined
|
|
92
96
|
? {}
|
|
93
|
-
: { body: (isForm ? options.body :
|
|
97
|
+
: { body: (isForm ? options.body : body) }),
|
|
94
98
|
...(timeoutSignal(options.timeoutMs) ?? {}),
|
|
95
99
|
};
|
|
96
100
|
try {
|
|
97
|
-
|
|
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.
|
|
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.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
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
|
}
|