@bli-cockpit/cli 0.2.149 → 0.2.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +1 -0
- package/dist/adapters/raw-evidence-facts.js +1 -0
- package/dist/adapters/raw-evidence-manifest.js +5 -0
- package/dist/adapters/raw-evidence-sanitize.js +82 -20
- package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
- package/dist/adapters/raw-evidence-transcript-file.js +10 -0
- package/dist/agent-rules.js +3 -3
- package/dist/commands/agent-door.js +3 -0
- package/dist/commands/backfill-lock-wait.js +49 -0
- package/dist/commands/backfill-outside-roots.js +97 -0
- package/dist/commands/backfill-reasons.js +17 -0
- package/dist/commands/backfill-scan.js +8 -3
- package/dist/commands/backfill-scope.js +14 -1
- package/dist/commands/backfill-session-report.js +3 -0
- package/dist/commands/backfill-upload.js +6 -0
- package/dist/commands/backfill.js +11 -5
- package/dist/commands/cal-calendars-write.js +107 -0
- package/dist/commands/cal.js +29 -110
- package/dist/commands/docs.js +20 -4
- package/dist/commands/doctor-budget.js +69 -0
- package/dist/commands/doctor-lock-wait.js +12 -3
- package/dist/commands/doctor-pipeline-verdicts.js +2 -0
- package/dist/commands/doctor-pipeline.js +5 -2
- package/dist/commands/doctor.js +26 -4
- package/dist/commands/issue-directory.js +7 -0
- package/dist/commands/issue.js +23 -4
- package/dist/commands/local-arg-values.js +9 -0
- package/dist/commands/local-args-collector-setup.js +4 -2
- package/dist/commands/local-args-collector-work.js +9 -1
- package/dist/commands/local-help-commands.js +4 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +14 -0
- package/dist/commands/mail.js +20 -4
- package/dist/commands/memory-store.js +27 -10
- package/dist/commands/msg.js +25 -5
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/read-cache.js +115 -0
- package/dist/commands/settings.js +17 -2
- package/dist/commands/tower-command.js +4 -0
- package/dist/cursors/raw-evidence-cursor.js +14 -0
- package/dist/tower-client.js +31 -3
- package/dist/upload-envelope-build.js +9 -0
- package/dist/upload-sync.js +10 -22
- package/dist/warm-tower-fetch.js +83 -0
- package/package.json +5 -5
|
@@ -105,6 +105,15 @@ export function optionalPositiveInteger(value, flag) {
|
|
|
105
105
|
}
|
|
106
106
|
return parsed;
|
|
107
107
|
}
|
|
108
|
+
export function optionalNonNegativeInteger(value, flag) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const parsed = Number(value);
|
|
112
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
113
|
+
throw new Error(`${flag} must be a whole number of 0 or more.`);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
108
117
|
export function optionalConfidence(value, flag) {
|
|
109
118
|
if (value === undefined)
|
|
110
119
|
return undefined;
|
|
@@ -142,8 +142,10 @@ export function parseDoctorArgs(alias, args) {
|
|
|
142
142
|
return {
|
|
143
143
|
kind: "doctor",
|
|
144
144
|
checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
// Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
|
|
146
|
+
// and `--json` without either flag runs under one bounded budget.
|
|
147
|
+
backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget"),
|
|
148
|
+
lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait"),
|
|
147
149
|
alias,
|
|
148
150
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
149
151
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* defaults, same refusal sentences.
|
|
9
9
|
*/
|
|
10
10
|
import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
|
|
11
|
-
import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
|
|
11
|
+
import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalNonNegativeInteger, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
|
|
12
12
|
export function parseStartArgs(args) {
|
|
13
13
|
const values = parseNamedArgs(args, {
|
|
14
14
|
allowedFlags: [
|
|
@@ -134,6 +134,7 @@ export function parseBackfillArgs(args) {
|
|
|
134
134
|
"--max-files",
|
|
135
135
|
"--max-depth",
|
|
136
136
|
"--max-repos",
|
|
137
|
+
"--lock-wait",
|
|
137
138
|
"--yes",
|
|
138
139
|
"--json",
|
|
139
140
|
],
|
|
@@ -146,6 +147,7 @@ export function parseBackfillArgs(args) {
|
|
|
146
147
|
"--max-files",
|
|
147
148
|
"--max-depth",
|
|
148
149
|
"--max-repos",
|
|
150
|
+
"--lock-wait",
|
|
149
151
|
],
|
|
150
152
|
});
|
|
151
153
|
assertNoPositionals(values.positionals, "backfill");
|
|
@@ -169,7 +171,13 @@ export function parseBackfillArgs(args) {
|
|
|
169
171
|
maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
|
|
170
172
|
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
171
173
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
174
|
+
...lockWaitMs(values.flags.get("--lock-wait")),
|
|
172
175
|
yes: values.booleans.has("--yes"),
|
|
173
176
|
json: values.booleans.has("--json"),
|
|
174
177
|
};
|
|
178
|
+
}
|
|
179
|
+
/** `--lock-wait <seconds>` (0 = try once); absent means the 10-minute default (BLI-11091). */
|
|
180
|
+
function lockWaitMs(value) {
|
|
181
|
+
const seconds = optionalNonNegativeInteger(value, "--lock-wait");
|
|
182
|
+
return seconds === undefined ? {} : { syncLockWaitMs: seconds * 1000 };
|
|
175
183
|
}
|
|
@@ -95,6 +95,7 @@ export function localSubcommandHelp(command) {
|
|
|
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.",
|
|
98
|
+
"--json without either flag answers within 45 seconds; a repair that does not fit reads doctor_budget_spent.",
|
|
98
99
|
"--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
|
|
99
100
|
"Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
|
|
100
101
|
],
|
|
@@ -267,7 +268,7 @@ export function localSubcommandHelp(command) {
|
|
|
267
268
|
[
|
|
268
269
|
"backfill",
|
|
269
270
|
[
|
|
270
|
-
"Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
271
|
+
"Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
|
|
271
272
|
"",
|
|
272
273
|
"Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
|
|
273
274
|
"Omit --source to scan both sources; there is no --source all literal.",
|
|
@@ -276,6 +277,8 @@ export function localSubcommandHelp(command) {
|
|
|
276
277
|
"Repo discovery scans 3 folder levels and up to 50 repos by default.",
|
|
277
278
|
"Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
|
|
278
279
|
"--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
|
|
280
|
+
"Sessions outside every saved root upload too, labelled outside; only an explicit opt-out withholds a session.",
|
|
281
|
+
"When the scheduled sync holds the collection lock, backfill waits up to --lock-wait seconds (default 600) and exits 1 with sync_lock_held if it never frees.",
|
|
279
282
|
],
|
|
280
283
|
],
|
|
281
284
|
[
|
|
@@ -109,7 +109,7 @@ export function localCommandHelp(command) {
|
|
|
109
109
|
" cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
|
|
110
110
|
" cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
|
|
111
111
|
" cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
|
|
112
|
-
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
112
|
+
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
|
|
113
113
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
114
114
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
115
115
|
" cockpit serve [--port <port>] [--workspace <path>]",
|
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.154");
|
|
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)
|
|
@@ -165,10 +165,14 @@ function parseObjectEntry(value) {
|
|
|
165
165
|
const committedAt = optionalString(record["committed_at"]);
|
|
166
166
|
if (!objectKey || !committedAt)
|
|
167
167
|
return null;
|
|
168
|
+
const sourceKey = optionalString(record["source_key"]);
|
|
168
169
|
return {
|
|
169
170
|
object_key: objectKey,
|
|
170
171
|
byte_size: optionalNumber(record["byte_size"]),
|
|
171
172
|
committed_at: committedAt,
|
|
173
|
+
...(sourceKey
|
|
174
|
+
? { source_key: sourceKey, checkpoints: parseCheckpoints(record["checkpoints"]) }
|
|
175
|
+
: {}),
|
|
172
176
|
};
|
|
173
177
|
}
|
|
174
178
|
function parseSessionEntry(value) {
|
|
@@ -193,6 +197,16 @@ function parseSessionEntry(value) {
|
|
|
193
197
|
last_seen_at: lastSeenAt,
|
|
194
198
|
};
|
|
195
199
|
}
|
|
200
|
+
function parseCheckpoints(value) {
|
|
201
|
+
if (!value || typeof value !== "object")
|
|
202
|
+
return null;
|
|
203
|
+
const out = {};
|
|
204
|
+
for (const [offset, hash] of Object.entries(value)) {
|
|
205
|
+
if (/^\d+$/.test(offset) && typeof hash === "string")
|
|
206
|
+
out[offset] = hash;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
196
210
|
function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
|
|
197
211
|
return path.join(paths.cursors_dir, filename);
|
|
198
212
|
}
|
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) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `upload-session-reports.ts`, which builds its own provenance without a
|
|
15
15
|
* full envelope.
|
|
16
16
|
*/
|
|
17
|
+
import { priorSnapshotsFromCursor, } from "./adapters/raw-evidence-snapshot-supersede.js";
|
|
17
18
|
import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
|
|
18
19
|
import path from "node:path";
|
|
19
20
|
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
@@ -120,6 +121,10 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
120
121
|
operatorId: session.operator_id,
|
|
121
122
|
workContextId: uploadContext.work_context_id,
|
|
122
123
|
}),
|
|
124
|
+
rawEvidencePriorSnapshots: priorSnapshotsFromCursor(transcriptCursorObjects(options.cursorObjects), (objectKey) => rawEvidenceObjectKeyBelongsToWorkContext(objectKey, {
|
|
125
|
+
operatorId: session.operator_id,
|
|
126
|
+
workContextId: uploadContext.work_context_id,
|
|
127
|
+
})),
|
|
123
128
|
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
124
129
|
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
125
130
|
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
@@ -188,6 +193,10 @@ function reusableContentHashes(options, context) {
|
|
|
188
193
|
.filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
|
|
189
194
|
.map(([hash]) => hash));
|
|
190
195
|
}
|
|
196
|
+
/** Cursor entries that remember a transcript source (BLI-11088); the rest are ignored. */
|
|
197
|
+
function transcriptCursorObjects(objects) {
|
|
198
|
+
return Object.fromEntries(Object.entries(objects ?? {}).filter((pair) => "source_key" in pair[1] && Boolean(pair[1].source_key)));
|
|
199
|
+
}
|
|
191
200
|
export function makeUploadWorkContext(options) {
|
|
192
201
|
const provenance = makeCollectorProvenance({
|
|
193
202
|
context: options.activeContext,
|