@bli-cockpit/cli 0.2.193 → 0.2.195
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-setup-observed.js +131 -0
- package/dist/commands/agent-setup.js +90 -20
- package/dist/commands/local-args-tower-setup.js +1 -1
- package/dist/commands/local-args-tower-speed.js +52 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +25 -0
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +2 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +8 -2
- package/dist/commands/setup.js +6 -3
- package/dist/commands/speed-math.js +135 -0
- package/dist/commands/speed-pages.js +38 -0
- package/dist/commands/speed-seen.js +119 -0
- package/dist/commands/speed.js +134 -0
- package/dist/session-commits.js +122 -0
- package/package.json +4 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const TRANSCRIPT_FILES = 12;
|
|
4
|
+
export const TRANSCRIPT_WINDOW_MS = 30 * 86_400_000;
|
|
5
|
+
const CHUNK_BYTES = 200_000;
|
|
6
|
+
const MAX_STATTED = 3000;
|
|
7
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
|
8
|
+
/** Newer of two semver-looking strings by numeric core; a non-version never wins. */
|
|
9
|
+
export function newerVersion(a, b) {
|
|
10
|
+
if (!b || !SEMVER.test(b))
|
|
11
|
+
return a;
|
|
12
|
+
if (!a || !SEMVER.test(a))
|
|
13
|
+
return b;
|
|
14
|
+
const pa = a.split(/[.-]/).slice(0, 3).map(Number), pb = b.split(/[.-]/).slice(0, 3).map(Number);
|
|
15
|
+
for (let i = 0; i < 3; i++)
|
|
16
|
+
if (pa[i] !== pb[i])
|
|
17
|
+
return pb[i] > pa[i] ? b : a;
|
|
18
|
+
return a;
|
|
19
|
+
}
|
|
20
|
+
/** The most frequent key, ties to the one seen first. */
|
|
21
|
+
function top(counts) {
|
|
22
|
+
let best = null, n = 0;
|
|
23
|
+
for (const [key, count] of counts)
|
|
24
|
+
if (count > n) {
|
|
25
|
+
best = key;
|
|
26
|
+
n = count;
|
|
27
|
+
}
|
|
28
|
+
return best;
|
|
29
|
+
}
|
|
30
|
+
/** Fold sampled transcript text into one client reading. Pure, so tests feed it fixtures. */
|
|
31
|
+
export function observeTranscripts(tool, samples) {
|
|
32
|
+
const models = new Map(), efforts = new Map();
|
|
33
|
+
let version = null;
|
|
34
|
+
const bump = (map, value) => {
|
|
35
|
+
if (typeof value === "string" && value && !value.startsWith("<"))
|
|
36
|
+
map.set(value, (map.get(value) ?? 0) + 1);
|
|
37
|
+
};
|
|
38
|
+
for (const sample of samples)
|
|
39
|
+
for (const line of sample.split("\n")) {
|
|
40
|
+
if (!line.startsWith("{"))
|
|
41
|
+
continue;
|
|
42
|
+
let entry;
|
|
43
|
+
try {
|
|
44
|
+
entry = JSON.parse(line);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
} // a chunk edge can cut a line
|
|
49
|
+
const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
|
|
50
|
+
if (tool === "claude") {
|
|
51
|
+
if (typeof entry.version === "string")
|
|
52
|
+
version = newerVersion(version, entry.version);
|
|
53
|
+
const message = entry.message && typeof entry.message === "object" ? entry.message : {};
|
|
54
|
+
if (entry.type === "assistant")
|
|
55
|
+
bump(models, message.model);
|
|
56
|
+
}
|
|
57
|
+
else if (entry.type === "session_meta") {
|
|
58
|
+
if (typeof payload.cli_version === "string")
|
|
59
|
+
version = newerVersion(version, payload.cli_version);
|
|
60
|
+
}
|
|
61
|
+
else if (entry.type === "turn_context") {
|
|
62
|
+
bump(models, payload.model);
|
|
63
|
+
bump(efforts, payload.effort);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { version, model: top(models), effort: top(efforts), files: samples.length };
|
|
67
|
+
}
|
|
68
|
+
async function sampleFile(file) {
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
handle = await fs.open(file, "r");
|
|
72
|
+
const { size } = await handle.stat();
|
|
73
|
+
const read = async (position, length) => {
|
|
74
|
+
const buffer = Buffer.alloc(length);
|
|
75
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
76
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
77
|
+
};
|
|
78
|
+
if (size <= CHUNK_BYTES * 2)
|
|
79
|
+
return await read(0, size);
|
|
80
|
+
return `${await read(0, CHUNK_BYTES)}\n${await read(size - CHUNK_BYTES, CHUNK_BYTES)}`;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await handle?.close().catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const entries = (directory) => fs.readdir(directory).catch(() => []);
|
|
90
|
+
async function newestClaudeFiles(home, now) {
|
|
91
|
+
const root = path.join(home, ".claude", "projects");
|
|
92
|
+
const found = [];
|
|
93
|
+
let statted = 0;
|
|
94
|
+
for (const project of await entries(root)) {
|
|
95
|
+
for (const name of await entries(path.join(root, project))) {
|
|
96
|
+
if (!name.endsWith(".jsonl") || statted++ >= MAX_STATTED)
|
|
97
|
+
continue;
|
|
98
|
+
const file = path.join(root, project, name);
|
|
99
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
100
|
+
if (stat && now - stat.mtimeMs <= TRANSCRIPT_WINDOW_MS)
|
|
101
|
+
found.push({ file, mtime: stat.mtimeMs });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return found.sort((a, b) => b.mtime - a.mtime).slice(0, TRANSCRIPT_FILES).map(entry => entry.file);
|
|
105
|
+
}
|
|
106
|
+
async function newestCodexFiles(home) {
|
|
107
|
+
const root = path.join(home, ".codex", "sessions");
|
|
108
|
+
const files = [];
|
|
109
|
+
const newestFirst = async (directory) => (await entries(directory)).sort().reverse();
|
|
110
|
+
for (const year of await newestFirst(root))
|
|
111
|
+
for (const month of await newestFirst(path.join(root, year)))
|
|
112
|
+
for (const day of await newestFirst(path.join(root, year, month))) {
|
|
113
|
+
for (const name of await newestFirst(path.join(root, year, month, day)))
|
|
114
|
+
if (name.endsWith(".jsonl") && files.length < TRANSCRIPT_FILES)
|
|
115
|
+
files.push(path.join(root, year, month, day, name));
|
|
116
|
+
if (files.length >= TRANSCRIPT_FILES)
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
/** Text samples of the newest transcripts of one tool in the last 30 days. Never throws. */
|
|
122
|
+
export async function readTranscriptSamples(tool, home, now = Date.now()) {
|
|
123
|
+
try {
|
|
124
|
+
const files = tool === "claude" ? await newestClaudeFiles(home, now) : await newestCodexFiles(home);
|
|
125
|
+
const samples = await Promise.all(files.map(sampleFile));
|
|
126
|
+
return samples.filter((sample) => sample !== null);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -5,22 +5,45 @@ import { execFile } from "node:child_process";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { AgentSetupSchema, SetupNameSchema } from "@bli-cockpit/telemetry-core";
|
|
7
7
|
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
8
|
+
import { newerVersion, observeTranscripts, readTranscriptSamples } from "./agent-setup-observed.js";
|
|
9
|
+
/** `--version` of a cold Claude start under launchd routinely took over 750 ms (BLI-13055). */
|
|
10
|
+
export const VERSION_CHECK_TIMEOUT_MS = 5000;
|
|
8
11
|
const exec = promisify(execFile);
|
|
9
12
|
const files = {
|
|
10
13
|
read: file => fs.readFile(file, "utf8"), list: directory => fs.readdir(directory),
|
|
11
14
|
version: async (tool, platform) => {
|
|
12
15
|
try {
|
|
13
|
-
const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout:
|
|
16
|
+
const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout: VERSION_CHECK_TIMEOUT_MS, maxBuffer: 4096, windowsHide: true });
|
|
14
17
|
return /(?:^|\s)(\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?)(?:\s|$)/i.exec(stdout)?.[1] ?? null;
|
|
15
18
|
}
|
|
16
19
|
catch {
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
19
22
|
},
|
|
23
|
+
transcripts: (tool, home) => readTranscriptSamples(tool, home),
|
|
20
24
|
};
|
|
21
25
|
const safeName = (value) => SetupNameSchema.safeParse(value).success ? value : null;
|
|
22
26
|
const names = (values) => [...new Set(values.flatMap(value => safeName(value) ? [value] : []))].sort().slice(0, 500);
|
|
23
27
|
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
28
|
+
/**
|
|
29
|
+
* Claude Code's default effort per model, from its own docs
|
|
30
|
+
* (https://code.claude.com/docs/en/model-config#adjust-effort-level, read
|
|
31
|
+
* 2026-10-08): `high` on every model that supports effort, except Opus 5.5,
|
|
32
|
+
* Sonnet 5.5 and Haiku 5.5 (`medium`) and Opus 4.7 (`xhigh`). A model this
|
|
33
|
+
* table does not know gets null, shown as "default" without a value.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeDefaultEffort(model) {
|
|
36
|
+
if (!model)
|
|
37
|
+
return null;
|
|
38
|
+
if (/^claude-(opus|sonnet|haiku)-5-5/i.test(model))
|
|
39
|
+
return "medium";
|
|
40
|
+
if (/^claude-opus-4-7/i.test(model))
|
|
41
|
+
return "xhigh";
|
|
42
|
+
if (/^claude-(opus|sonnet)-(?:4-[68]|5)(?!-\d)/i.test(model))
|
|
43
|
+
return "high";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const claudeFamily = (model) => /opus|sonnet|haiku/i.exec(model ?? "")?.[0].toLowerCase() ?? null;
|
|
24
47
|
/** Bounded, explicit locations only. Never recurse through a home or read env files. */
|
|
25
48
|
export async function collectAgentSetup(home, platform = process.platform, io = files, now = new Date()) {
|
|
26
49
|
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
@@ -53,25 +76,58 @@ export async function collectAgentSetup(home, platform = process.platform, io =
|
|
|
53
76
|
return null;
|
|
54
77
|
}
|
|
55
78
|
};
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
const packageName = (tool) => tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
|
|
80
|
+
const versionFromPackage = async (tool) => {
|
|
81
|
+
const parts = packageName(tool).split("/");
|
|
82
|
+
// Where npm puts a global package: Windows keeps it under AppData, macOS
|
|
83
|
+
// under the prefix of the node that installed it (Homebrew, /usr/local,
|
|
84
|
+
// a user prefix) or Claude Code's own ~/.claude/local install.
|
|
85
|
+
const roots = platform === "win32"
|
|
86
|
+
? [join("AppData", "Roaming", "npm", "node_modules")]
|
|
87
|
+
: [paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules"), "/opt/homebrew/lib/node_modules", "/usr/local/lib/node_modules", join(".npm-global", "lib", "node_modules")];
|
|
88
|
+
if (tool === "claude")
|
|
89
|
+
roots.push(join(".claude", "local", "node_modules"));
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
const text = await read(paths.join(root, ...parts, "package.json"));
|
|
92
|
+
try {
|
|
93
|
+
const version = safeName(object(JSON.parse(text ?? "null")).version);
|
|
94
|
+
if (version)
|
|
95
|
+
return version;
|
|
96
|
+
}
|
|
97
|
+
catch { /* unreadable metadata: try the next root */ }
|
|
68
98
|
}
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
if (tool === "claude") {
|
|
100
|
+
// Claude Code's native installer keeps one directory per version.
|
|
101
|
+
const native = platform === "win32" ? join("AppData", "Local", "claude", "versions") : join(".local", "share", "claude", "versions");
|
|
102
|
+
try {
|
|
103
|
+
return (await io.list(native)).reduce((newest, name) => newerVersion(newest, name.replace(/\.exe$/i, "")), null);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
71
108
|
}
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
const resolveVersion = async (tool, observing) => {
|
|
112
|
+
const viaCli = await io.version?.(tool, platform).catch(() => null);
|
|
113
|
+
if (safeName(viaCli))
|
|
114
|
+
return { value: viaCli, source: "cli" };
|
|
115
|
+
// npm shims on native Windows cannot be execFile'd without a shell. Read
|
|
116
|
+
// their installed package metadata instead, never the update-available cache.
|
|
117
|
+
const viaPackage = await versionFromPackage(tool);
|
|
118
|
+
if (viaPackage)
|
|
119
|
+
return { value: viaPackage, source: "package" };
|
|
120
|
+
const viaTranscript = safeName((await observing).version);
|
|
121
|
+
return viaTranscript ? { value: viaTranscript, source: "transcript" } : { value: null, source: "none" };
|
|
122
|
+
};
|
|
123
|
+
// Everything slow starts together: both `--version` checks (up to 5 s each) and
|
|
124
|
+
// both transcript samples run side by side, so the whole step costs one wait.
|
|
125
|
+
const observe = async (tool) => {
|
|
126
|
+
const samples = await io.transcripts?.(tool, home).catch(() => []) ?? [];
|
|
127
|
+
return observeTranscripts(tool, samples);
|
|
72
128
|
};
|
|
73
|
-
const
|
|
74
|
-
const codexVersion = await
|
|
129
|
+
const claudeObserving = observe("claude"), codexObserving = observe("codex");
|
|
130
|
+
const [claudeVersion, codexVersion, claudeObserved, codexObserved] = await Promise.all([resolveVersion("claude", claudeObserving), resolveVersion("codex", codexObserving), claudeObserving, codexObserving]);
|
|
75
131
|
const codexHooks = await json(".codex", "hooks.json");
|
|
76
132
|
const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
|
|
77
133
|
const pluginSkills = [], pluginAgents = [];
|
|
@@ -134,19 +190,33 @@ export async function collectAgentSetup(home, platform = process.platform, io =
|
|
|
134
190
|
return { name, exists: error.code === "ENOENT" ? false : null, sha256: null, bytes: null };
|
|
135
191
|
}
|
|
136
192
|
}));
|
|
193
|
+
// Model: what the person set, else what their sessions actually ran most.
|
|
194
|
+
const claudeSetModel = safeName(claudeSettings?.model), codexSetModel = scalar("model");
|
|
195
|
+
const claudeModel = claudeSetModel ?? claudeObserved.model;
|
|
196
|
+
// The default effort belongs to the model that really runs. A set alias such
|
|
197
|
+
// as "opus" has no version, so the observed full id of that family refines it.
|
|
198
|
+
const claudeDefaultFor = claudeSetModel && claudeFamily(claudeSetModel) !== claudeFamily(claudeObserved.model) ? claudeSetModel : claudeObserved.model ?? claudeSetModel;
|
|
199
|
+
const claudeSetEffort = safeName(claudeSettings?.effortLevel), codexSetEffort = scalar("model_reasoning_effort");
|
|
200
|
+
const claudeDefault = claudeSetEffort ? null : claudeDefaultEffort(claudeDefaultFor);
|
|
137
201
|
const result = AgentSetupSchema.parse({
|
|
138
202
|
checked_at: now.toISOString(), os: platform === "darwin" || platform === "win32" ? platform : "other", cli_version: LOCAL_COLLECTOR_VERSION,
|
|
139
|
-
claude: { version: claudeVersion,
|
|
203
|
+
claude: { version: claudeVersion.value, version_source: claudeVersion.source,
|
|
204
|
+
model: claudeModel, model_basis: claudeSetModel ? "set" : claudeModel ? "used" : "none",
|
|
205
|
+
effort: claudeSetEffort ?? claudeDefault, effort_basis: claudeSetEffort ? "set" : "default",
|
|
140
206
|
skills: claudeSkills === null || pluginInventoryUnknown ? null : names([...claudeSkills, ...pluginSkills]), agents: claudeAgents === null || pluginInventoryUnknown ? null : names([...claudeAgents, ...pluginAgents]),
|
|
141
207
|
plugins: claudePlugins === null ? null : names(Object.keys(object(claudePlugins.plugins))),
|
|
142
208
|
hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp },
|
|
143
|
-
codex: { version: codexVersion,
|
|
209
|
+
codex: { version: codexVersion.value, version_source: codexVersion.source,
|
|
210
|
+
model: codexSetModel ?? codexObserved.model, model_basis: codexSetModel ? "set" : codexObserved.model ? "used" : "none",
|
|
211
|
+
effort: codexSetEffort ?? codexObserved.effort, effort_basis: codexSetEffort ? "set" : codexObserved.effort ? "observed" : "default",
|
|
144
212
|
skills: codexSkills === null || sharedSkills === null || systemSkills === null ? null : names([...(codexSkills ?? []), ...(sharedSkills ?? []), ...(systemSkills ?? [])]),
|
|
145
213
|
agents: toml === null ? null : names([...toml.matchAll(/^\s*\[agents\.([a-z0-9_-]+)\]\s*$/gim)].map(match => match[1])),
|
|
146
214
|
plugins: toml === null ? null : names([...toml.matchAll(/^\s*\[plugins\."([a-z0-9_@.+-]+)"\]\s*$/gim)].map(match => match[1])),
|
|
147
215
|
hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp }, instructions,
|
|
148
216
|
});
|
|
149
|
-
const
|
|
217
|
+
const evidence = (tool) => ({ version_source: result[tool].version_source, model_basis: result[tool].model_basis, effort_basis: result[tool].effort_basis });
|
|
218
|
+
console.error("[setup] versions read", JSON.stringify({ reason: "version_resolved", claude: evidence("claude"), codex: evidence("codex"), transcript_files: { claude: claudeObserved.files, codex: codexObserved.files } }));
|
|
219
|
+
const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + ["skills", "plugins", "agents", "hooks", "mcp"].filter(key => tool[key] === null).length, 0);
|
|
150
220
|
console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
|
|
151
221
|
return result;
|
|
152
222
|
}
|
|
@@ -5,7 +5,7 @@ export function parseSetupArgs(args) {
|
|
|
5
5
|
throw new Error("setup takes --person or --compare, not a subcommand.");
|
|
6
6
|
const compare = optionalNonEmpty(values.flags.get("--compare"));
|
|
7
7
|
if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
|
|
8
|
-
throw new Error("--compare takes two emails separated by a comma.");
|
|
8
|
+
throw new Error("--compare takes two people (first names or emails) separated by a comma.");
|
|
9
9
|
const since = optionalNonEmpty(values.flags.get("--since"));
|
|
10
10
|
if (since && since !== "7d" && since !== "30d" && (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?$/.test(since) || !Number.isFinite(Date.parse(since)) || new Date(since).toISOString().slice(0, 10) !== since.slice(0, 10) || Date.parse(since) >= Date.now()))
|
|
11
11
|
throw new Error("--since takes 7d, 30d, or an ISO date before now.");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
|
+
import { parseSeenWindow } from "./speed-seen.js";
|
|
3
|
+
import { SPEED_PAGE_NAMES } from "./speed-pages.js";
|
|
4
|
+
/** The most runs one page gets; a speed check is a handful of reads, not a load test. */
|
|
5
|
+
export const SPEED_MAX_RUNS = 50;
|
|
6
|
+
/**
|
|
7
|
+
* `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033), and
|
|
8
|
+
* `cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--json]`
|
|
9
|
+
* for real-visit timings (BLI-13054).
|
|
10
|
+
*/
|
|
11
|
+
export function parseSpeedArgs(args) {
|
|
12
|
+
const values = parseNamedArgs(args, {
|
|
13
|
+
allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url", "--seen", "--since", "--everyone"],
|
|
14
|
+
valueFlags: ["--page", "--runs", "--home", "--dashboard-url", "--since"],
|
|
15
|
+
});
|
|
16
|
+
if (values.positionals.length) {
|
|
17
|
+
throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
|
|
18
|
+
}
|
|
19
|
+
const pages = (values.flagValues.get("--page") ?? []).map((name) => name.trim().toLowerCase());
|
|
20
|
+
const unknown = pages.filter((name) => !SPEED_PAGE_NAMES.includes(name));
|
|
21
|
+
if (unknown.length) {
|
|
22
|
+
throw new Error(`Unknown speed page: ${unknown.join(", ")}. Pages: ${SPEED_PAGE_NAMES.join(", ")}.`);
|
|
23
|
+
}
|
|
24
|
+
const runs = optionalPositiveInteger(values.flags.get("--runs"), "--runs");
|
|
25
|
+
if (runs !== undefined && runs > SPEED_MAX_RUNS) {
|
|
26
|
+
throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
|
|
27
|
+
}
|
|
28
|
+
const seen = values.booleans.has("--seen");
|
|
29
|
+
const sinceText = optionalNonEmpty(values.flags.get("--since"));
|
|
30
|
+
const everyone = values.booleans.has("--everyone");
|
|
31
|
+
if (!seen && (sinceText !== undefined || everyone)) {
|
|
32
|
+
throw new Error("--since and --everyone belong to `cockpit speed --seen`.");
|
|
33
|
+
}
|
|
34
|
+
if (seen && runs !== undefined)
|
|
35
|
+
throw new Error("--runs times the data doors; `--seen` reads real visits and takes no --runs.");
|
|
36
|
+
let since;
|
|
37
|
+
if (sinceText !== undefined) {
|
|
38
|
+
const window = parseSeenWindow(sinceText);
|
|
39
|
+
if (window === null)
|
|
40
|
+
throw new Error("--since takes <n>m, <n>h or <n>d, at most 30d (for example 24h).");
|
|
41
|
+
since = window;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
kind: "speed",
|
|
45
|
+
pages: [...new Set(pages)],
|
|
46
|
+
runs,
|
|
47
|
+
...(seen ? { seen: true, ...(since ? { since } : {}), ...(everyone ? { everyone: true } : {}) } : {}),
|
|
48
|
+
json: values.booleans.has("--json"),
|
|
49
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
50
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -38,6 +38,7 @@ export { parseModelsArgs } from "./local-args-tower-models.js";
|
|
|
38
38
|
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
39
39
|
export { parseCalArgs } from "./local-args-tower-cal.js";
|
|
40
40
|
export { parseSetupArgs } from "./local-args-tower-setup.js";
|
|
41
|
+
export { parseSpeedArgs } from "./local-args-tower-speed.js";
|
|
41
42
|
export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
42
43
|
export { parseLogsArgs } from "./local-args-tower-logs.js";
|
|
43
44
|
export { parseBrainArgs } from "./local-args-tower-brain.js";
|
|
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
|
|
|
19
19
|
import { parseRootsArgs } from "./roots.js";
|
|
20
20
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
21
21
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
22
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
22
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
23
23
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
24
24
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
25
25
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -138,6 +138,8 @@ export function parseLocalArgs(argv) {
|
|
|
138
138
|
return parseSetupArgs(argv.slice(1));
|
|
139
139
|
case "usage":
|
|
140
140
|
return parseUsageArgs(argv.slice(1));
|
|
141
|
+
case "speed":
|
|
142
|
+
return parseSpeedArgs(argv.slice(1));
|
|
141
143
|
case "logs":
|
|
142
144
|
return parseLogsArgs(argv.slice(1));
|
|
143
145
|
case "brain":
|
|
@@ -175,6 +175,31 @@ export const TOWER_COMMAND_HELP = [
|
|
|
175
175
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
176
176
|
],
|
|
177
177
|
],
|
|
178
|
+
[
|
|
179
|
+
"speed",
|
|
180
|
+
[
|
|
181
|
+
"Usage: cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
|
|
182
|
+
" cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--dashboard-url <url>] [--json]",
|
|
183
|
+
"",
|
|
184
|
+
"Times every Tower rail page's data door from this terminal, with this machine's device token.",
|
|
185
|
+
"Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
|
|
186
|
+
"(nearest rank). Also the HTTP status, the response size and the door's own Server-Timing total.",
|
|
187
|
+
"Slowest first. A page your account may not open (Careers for a member) reads \"not permitted\", not as a failure.",
|
|
188
|
+
"--page <name> limits the run to one page; repeat it for several. Names: today, meeting-notes, careers, scout,",
|
|
189
|
+
"messages, mail, docs, memory, tickets, setup, usage, calendar, workbook, settings.",
|
|
190
|
+
"--runs <n> sets the requests per page (default 5, at most 50).",
|
|
191
|
+
"--json writes one object to stdout ({ ok, runs, pages: [...] }); every log line stays on stderr.",
|
|
192
|
+
"It times the DATA DOOR the page opens on, not the page's HTML or paint, and it never uses the local read cache.",
|
|
193
|
+
"Exits 1 when any page failed; a page that is not permitted does not fail the run.",
|
|
194
|
+
"The same measurement is the speed_read MCP tool.",
|
|
195
|
+
"",
|
|
196
|
+
"--seen reads what REAL visits saw instead: per page the visit count, p50 and p95 first-useful milliseconds (the moment",
|
|
197
|
+
"real content showed, from the last-known screen or the fresh one), the share painted from the snapshot, and median HTML",
|
|
198
|
+
"and script transfer. The browser sends one metadata-only beacon per page view; nothing is measured from this machine.",
|
|
199
|
+
"--since <n>m|h|d is the window (default 24h, at most 30d). --everyone reads every person's visits (super_admin only).",
|
|
200
|
+
"A member sees their own visits. --page limits the table to those pages. The same read is the speed_seen MCP tool.",
|
|
201
|
+
],
|
|
202
|
+
],
|
|
178
203
|
[
|
|
179
204
|
"usage",
|
|
180
205
|
[
|
|
@@ -27,7 +27,7 @@ export function localSubcommandHelp(command) {
|
|
|
27
27
|
// BLI-3709: the Tower nouns live next door, split along the same seam the
|
|
28
28
|
// argument parsers already use — see `local-help-commands-tower.ts`.
|
|
29
29
|
...TOWER_COMMAND_HELP,
|
|
30
|
-
["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <
|
|
30
|
+
["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
|
|
31
31
|
[
|
|
32
32
|
"onboard",
|
|
33
33
|
[
|
|
@@ -83,6 +83,7 @@ export const rootCommandNames = new Set([
|
|
|
83
83
|
"rename",
|
|
84
84
|
"setup",
|
|
85
85
|
"usage",
|
|
86
|
+
"speed",
|
|
86
87
|
"logs",
|
|
87
88
|
"brain",
|
|
88
89
|
...SURFACE_ALIASES.keys(),
|
|
@@ -143,7 +144,7 @@ export function localCommandHelp(command) {
|
|
|
143
144
|
" cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
|
|
144
145
|
" cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
|
|
145
146
|
" cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
|
|
146
|
-
" cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <
|
|
147
|
+
" cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
|
|
147
148
|
" cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
|
|
148
149
|
" cockpit usage sessions --session <id> [--from-window <n>] [--json]",
|
|
149
150
|
" --by-topic groups work types; --by-subject groups open subjects from session summaries.",
|
|
@@ -153,6 +154,7 @@ export function localCommandHelp(command) {
|
|
|
153
154
|
" cockpit calendar [same verbs and flags as cockpit cal]",
|
|
154
155
|
" cockpit messages [same verbs and flags as cockpit msg]",
|
|
155
156
|
" cockpit meeting-notes [same verbs and flags as cockpit notes]",
|
|
157
|
+
" cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
|
|
156
158
|
" cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
|
|
157
159
|
"",
|
|
158
160
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
package/dist/commands/local.js
CHANGED
|
@@ -52,6 +52,7 @@ import { runCareers } from "./careers.js";
|
|
|
52
52
|
import { runRename } from "./rename-scope.js";
|
|
53
53
|
import { runSetup } from "./setup.js";
|
|
54
54
|
import { runUsage } from "./usage.js";
|
|
55
|
+
import { runSpeed } from "./speed.js";
|
|
55
56
|
import { runLogs } from "./logs.js";
|
|
56
57
|
import { runBrain } from "./brain.js";
|
|
57
58
|
import { parseLocalArgs } from "./local-args.js";
|
|
@@ -249,6 +250,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
|
|
|
249
250
|
return await runRename(command, io);
|
|
250
251
|
case "setup": return await runSetup(command, io);
|
|
251
252
|
case "usage": return await runUsage(command, io);
|
|
253
|
+
case "speed": return await runSpeed(command, io);
|
|
252
254
|
case "logs": return await runLogs(command, io);
|
|
253
255
|
case "brain": return await runBrain(command, io);
|
|
254
256
|
}
|
|
@@ -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.195");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -41,6 +41,7 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
|
|
|
41
41
|
import os from "node:os";
|
|
42
42
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
43
43
|
import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
|
|
44
|
+
import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
|
|
44
45
|
import { planSyncFromLocalState } from "./session-sync-plan.js";
|
|
45
46
|
import { scanAndAttributeBothSources } from "./session-sync-scan.js";
|
|
46
47
|
import { syncEveryTargetWorktree } from "./session-sync-upload.js";
|
|
@@ -100,14 +101,19 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
100
101
|
ticketByWorktree.set(worktree.worktree_fingerprint, ticket);
|
|
101
102
|
}
|
|
102
103
|
console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
|
|
103
|
-
|
|
104
|
+
// BLI-13028: which commits each session's working tree created while it ran.
|
|
105
|
+
const commitsBySession = await collectSessionCommits([
|
|
106
|
+
...scan.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
|
|
107
|
+
...scan.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
|
|
108
|
+
]);
|
|
109
|
+
const sessions = withSessionCommits(buildAgentSessionReport({
|
|
104
110
|
ticketByWorktree,
|
|
105
111
|
codexResults: scan.codexAttribution.results,
|
|
106
112
|
claudeResults: scan.claudeAttribution.results,
|
|
107
113
|
outcomes: worktreePass.outcomes,
|
|
108
114
|
now,
|
|
109
115
|
claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
|
|
110
|
-
});
|
|
116
|
+
}), commitsBySession);
|
|
111
117
|
// BLI-4341: every session should arrive counted. Say how many did, and say
|
|
112
118
|
// it on the success path too: a silent drop from "all" to "some" is exactly
|
|
113
119
|
// the kind of decay nobody notices.
|
package/dist/commands/setup.js
CHANGED
|
@@ -24,12 +24,15 @@ export async function runSetup(command, io) {
|
|
|
24
24
|
writeLine(io.stdout, " No setup received.");
|
|
25
25
|
for (const machine of person.machines) {
|
|
26
26
|
if (!machine.setup) {
|
|
27
|
-
writeLine(io.stdout, "
|
|
27
|
+
writeLine(io.stdout, " No readable setup from this machine yet.");
|
|
28
28
|
continue;
|
|
29
29
|
}
|
|
30
30
|
writeLine(io.stdout, ` ${machine.setup.os} · TOWER CLI ${machine.setup.cli_version}${machine.stale ? " · stale setup" : ""}`);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The server words every model, effort and version (BLI-13055); an older server sends only the raw receipt.
|
|
32
|
+
for (const tool of ["claude", "codex"]) {
|
|
33
|
+
const words = machine.setup_labels?.[tool];
|
|
34
|
+
writeLine(io.stdout, words ? ` ${tool}: model ${words.model}, effort ${words.effort}, version ${words.version}` : ` ${tool}: model ${machine.setup[tool].model ?? "not reported by this machine yet"}, effort ${machine.setup[tool].effort ?? "default"}`);
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
for (const pair of body.comparison ?? []) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic and the layout behind `cockpit speed` (BLI-13033). Pure: no
|
|
3
|
+
* clock, no network, so the timing math, the ranking and the "not permitted"
|
|
4
|
+
* rule are all tested with plain numbers.
|
|
5
|
+
*/
|
|
6
|
+
/** 401 is a bad token (a failure to fix); 403 is a page this caller may not open. */
|
|
7
|
+
export function classifyStatus(status) {
|
|
8
|
+
if (status === 403)
|
|
9
|
+
return "not_permitted";
|
|
10
|
+
if (status !== null && status >= 200 && status < 300)
|
|
11
|
+
return "ok";
|
|
12
|
+
return "failed";
|
|
13
|
+
}
|
|
14
|
+
/** Nearest-rank percentile over unsorted values. 0 < p <= 100. Null on no values. */
|
|
15
|
+
export function percentile(values, p) {
|
|
16
|
+
if (values.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
19
|
+
const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
|
|
20
|
+
return sorted[Math.min(rank, sorted.length) - 1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** `total;dur=123.4` out of a Server-Timing header. */
|
|
23
|
+
export function serverTotalMs(header) {
|
|
24
|
+
if (!header)
|
|
25
|
+
return null;
|
|
26
|
+
for (const part of header.split(",")) {
|
|
27
|
+
const [name, ...params] = part.trim().split(";");
|
|
28
|
+
if (name?.trim() !== "total")
|
|
29
|
+
continue;
|
|
30
|
+
for (const param of params) {
|
|
31
|
+
const [key, value] = param.trim().split("=");
|
|
32
|
+
if (key === "dur" && value !== undefined && Number.isFinite(Number(value)))
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const round1 = (value) => (value === null ? null : Math.round(value * 10) / 10);
|
|
39
|
+
export function summarizeSamples(page, samples) {
|
|
40
|
+
const base = { page: page.name, label: page.label, door: page.path, ...(page.note ? { note: page.note } : {}) };
|
|
41
|
+
const last = samples[samples.length - 1];
|
|
42
|
+
const good = samples.filter((sample) => classifyStatus(sample.status) === "ok");
|
|
43
|
+
const denied = samples.find((sample) => classifyStatus(sample.status) === "not_permitted");
|
|
44
|
+
if (good.length === 0) {
|
|
45
|
+
const first = samples[0];
|
|
46
|
+
const outcome = denied ? "not_permitted" : "failed";
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
outcome,
|
|
50
|
+
cold_ms: null,
|
|
51
|
+
p50_ms: null,
|
|
52
|
+
p95_ms: null,
|
|
53
|
+
runs: samples.length,
|
|
54
|
+
failed_runs: denied ? 0 : samples.length,
|
|
55
|
+
http_status: (denied ?? first)?.status ?? null,
|
|
56
|
+
bytes: null,
|
|
57
|
+
server_timing: null,
|
|
58
|
+
server_total_ms: null,
|
|
59
|
+
reason: denied
|
|
60
|
+
? "not permitted for this account"
|
|
61
|
+
: (first?.failure ?? (first?.status === null || first === undefined ? "no_response" : `http_${first.status}`)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const cold = samples[0] && classifyStatus(samples[0].status) === "ok" ? samples[0] : null;
|
|
65
|
+
const warm = good.filter((sample) => sample !== cold);
|
|
66
|
+
const timed = (warm.length > 0 ? warm : good).map((sample) => sample.ms);
|
|
67
|
+
const lastGood = good[good.length - 1] ?? last;
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
outcome: "ok",
|
|
71
|
+
cold_ms: round1(cold ? cold.ms : null),
|
|
72
|
+
p50_ms: round1(percentile(timed, 50)),
|
|
73
|
+
p95_ms: round1(percentile(timed, 95)),
|
|
74
|
+
runs: samples.length,
|
|
75
|
+
failed_runs: samples.length - good.length,
|
|
76
|
+
http_status: lastGood?.status ?? null,
|
|
77
|
+
bytes: lastGood?.bytes ?? null,
|
|
78
|
+
server_timing: lastGood?.serverTiming ?? null,
|
|
79
|
+
server_total_ms: serverTotalMs(lastGood?.serverTiming ?? null),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Slowest first by median. Pages that answered come first, then pages that
|
|
84
|
+
* failed (a failure is something to fix, so it sits above the pages that were
|
|
85
|
+
* simply not open to this account), then pages not permitted.
|
|
86
|
+
*/
|
|
87
|
+
export function rankPages(pages) {
|
|
88
|
+
const tier = (page) => (page.outcome === "ok" ? 0 : page.outcome === "failed" ? 1 : 2);
|
|
89
|
+
return [...pages].sort((a, b) => {
|
|
90
|
+
if (tier(a) !== tier(b))
|
|
91
|
+
return tier(a) - tier(b);
|
|
92
|
+
return (b.p50_ms ?? 0) - (a.p50_ms ?? 0) || a.page.localeCompare(b.page);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function pad(value, width, right = false) {
|
|
96
|
+
return right ? value.padStart(width) : value.padEnd(width);
|
|
97
|
+
}
|
|
98
|
+
function ms(value) {
|
|
99
|
+
return value === null ? "-" : `${Math.round(value)} ms`;
|
|
100
|
+
}
|
|
101
|
+
function size(bytes) {
|
|
102
|
+
if (bytes === null)
|
|
103
|
+
return "-";
|
|
104
|
+
if (bytes < 1024)
|
|
105
|
+
return `${bytes} B`;
|
|
106
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
107
|
+
}
|
|
108
|
+
/** The ranked table a person reads. Already ranked: this lays out, it does not sort. */
|
|
109
|
+
export function renderSpeedTable(ranked, runs) {
|
|
110
|
+
const rows = ranked.map((page, index) => {
|
|
111
|
+
if (page.outcome === "ok") {
|
|
112
|
+
return [
|
|
113
|
+
String(index + 1),
|
|
114
|
+
page.page,
|
|
115
|
+
ms(page.cold_ms),
|
|
116
|
+
ms(page.p50_ms),
|
|
117
|
+
ms(page.p95_ms),
|
|
118
|
+
String(page.http_status ?? "-"),
|
|
119
|
+
size(page.bytes),
|
|
120
|
+
page.server_total_ms === null ? "-" : ms(page.server_total_ms),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
const word = page.outcome === "not_permitted" ? "not permitted" : `FAILED (${page.reason ?? "unknown"})`;
|
|
124
|
+
return [String(index + 1), page.page, word, "", "", String(page.http_status ?? "-"), "-", "-"];
|
|
125
|
+
});
|
|
126
|
+
const head = ["#", "PAGE", "COLD", "P50", "P95", "HTTP", "BYTES", "SERVER"];
|
|
127
|
+
const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
128
|
+
const line = (cells) => cells.map((cell, column) => pad(cell, widths[column] ?? 0, column >= 2 && column !== 5)).join(" ").trimEnd();
|
|
129
|
+
return [
|
|
130
|
+
line(head),
|
|
131
|
+
...rows.map(line),
|
|
132
|
+
"",
|
|
133
|
+
`${runs} run${runs === 1 ? "" : "s"} per page: the first is cold, p50 and p95 are over the warm runs${runs === 1 ? " (only the cold run was made)" : ""}. SERVER is the door's own Server-Timing total.`,
|
|
134
|
+
];
|
|
135
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which door each rail page opens on (BLI-13033).
|
|
3
|
+
*
|
|
4
|
+
* `cockpit speed` times the DATA DOOR of every rail page: the API read the
|
|
5
|
+
* page's first useful content comes from, or (where the page reads the
|
|
6
|
+
* database straight from the server) the nearest door that reads the same
|
|
7
|
+
* rows. It does not time the page's HTML, hydration or paint, so a number here
|
|
8
|
+
* is the floor of what a person waits, never more than that.
|
|
9
|
+
*
|
|
10
|
+
* Order is the rail's order (`ck-rail.tsx` `NAV_ITEMS`). The MCP twin
|
|
11
|
+
* (`packages/bli-cockpit-mcp/src/speed-tools.ts`) carries its own copy, and a
|
|
12
|
+
* test in that package fails when the two lists drift.
|
|
13
|
+
*/
|
|
14
|
+
/** The pages in rail order. `memoryContainer` is the tag `cockpit memory profile` would use here. */
|
|
15
|
+
export function speedPages(memoryContainer) {
|
|
16
|
+
return [
|
|
17
|
+
{ name: "today", label: "Today (JARVIS home)", path: "/api/jarvis/brief" },
|
|
18
|
+
{ name: "meeting-notes", label: "Meeting notes", path: "/api/notes/library" },
|
|
19
|
+
{ name: "careers", label: "Careers", path: "/api/careers/applications" },
|
|
20
|
+
{ name: "scout", label: "Scout", path: "/api/cockpit/scout" },
|
|
21
|
+
{ name: "messages", label: "Messages", path: "/api/msg/channels" },
|
|
22
|
+
{ name: "mail", label: "Mail", path: "/api/mail/inbox" },
|
|
23
|
+
{ name: "docs", label: "Docs", path: "/api/docs/tree" },
|
|
24
|
+
{
|
|
25
|
+
name: "memory",
|
|
26
|
+
label: "Memory",
|
|
27
|
+
path: `/api/memory/profile?container=${encodeURIComponent(memoryContainer)}`,
|
|
28
|
+
note: "the page reads agent_memories on the server; the profile door is the nearest read",
|
|
29
|
+
},
|
|
30
|
+
{ name: "tickets", label: "Tickets", path: "/api/work/issues" },
|
|
31
|
+
{ name: "setup", label: "Setup", path: "/api/setup" },
|
|
32
|
+
{ name: "usage", label: "Usage", path: "/api/usage/people?since=30d" },
|
|
33
|
+
{ name: "calendar", label: "Calendar", path: "/api/cal/week" },
|
|
34
|
+
{ name: "workbook", label: "Workbook", path: "/api/cockpit/workbook" },
|
|
35
|
+
{ name: "settings", label: "Settings", path: "/api/settings/personal" },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export const SPEED_PAGE_NAMES = speedPages("x").map((page) => page.name);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit speed --seen [--since 24h] [--everyone]` (BLI-13054) — what real
|
|
3
|
+
* visits experienced on each Tower page, from the page-view beacons the browser
|
|
4
|
+
* sends (`apps/dashboard/src/lib/speed/first-useful.ts`).
|
|
5
|
+
*
|
|
6
|
+
* `cockpit speed` times a page's DATA DOOR from this terminal. It cannot see a
|
|
7
|
+
* page painted instantly from its last-known screen, or one that no longer
|
|
8
|
+
* waits to fetch after it loads. A visit's number is the moment real content
|
|
9
|
+
* first appeared, on that person's own network. Per page: visits, p50 and p95
|
|
10
|
+
* first-useful milliseconds, the share painted from the snapshot, and the
|
|
11
|
+
* median HTML and script transfer where the browser knew them.
|
|
12
|
+
*
|
|
13
|
+
* The door (`GET /api/speed/seen`) does the counting in the database, on the
|
|
14
|
+
* caller's own row policies: a person sees their own visits; `--everyone` needs
|
|
15
|
+
* a super_admin and is refused for anyone else. stdout is `--json`'s alone;
|
|
16
|
+
* every log line goes to stderr, metadata only (counts, a window, a reason).
|
|
17
|
+
*/
|
|
18
|
+
import { writeLine } from "./cli-io.js";
|
|
19
|
+
import { loadPairedSession, towerFailureDetail, towerJsonRequest } from "../tower-client.js";
|
|
20
|
+
export const SEEN_DEFAULT_SINCE = "24h";
|
|
21
|
+
const SEEN_MAX_DAYS = 30;
|
|
22
|
+
const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
23
|
+
const REQUEST_DEADLINE_MS = 60_000;
|
|
24
|
+
/** `90m`, `24h`, `7d`, at most 30 days (rows are pruned after that); null otherwise. */
|
|
25
|
+
export function parseSeenWindow(text) {
|
|
26
|
+
const match = /^(\d{1,4})([mhd])$/.exec(text.trim().toLowerCase());
|
|
27
|
+
if (!match)
|
|
28
|
+
return null;
|
|
29
|
+
const ms = Number(match[1]) * UNIT_MS[match[2]];
|
|
30
|
+
return ms > 0 && ms <= SEEN_MAX_DAYS * UNIT_MS.d ? `${match[1]}${match[2]}` : null;
|
|
31
|
+
}
|
|
32
|
+
const ms = (value) => (value === null ? "-" : `${Math.round(value)} ms`);
|
|
33
|
+
const percent = (share) => `${Math.round(share * 100)}%`;
|
|
34
|
+
function size(bytes) {
|
|
35
|
+
if (bytes === null)
|
|
36
|
+
return "-";
|
|
37
|
+
if (bytes < 1024)
|
|
38
|
+
return `${bytes} B`;
|
|
39
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
40
|
+
}
|
|
41
|
+
/** Slowest median first, like `cockpit speed`; ties by page name. */
|
|
42
|
+
export function rankSeen(pages) {
|
|
43
|
+
return [...pages].sort((a, b) => b.p50_ms - a.p50_ms || a.page.localeCompare(b.page));
|
|
44
|
+
}
|
|
45
|
+
/** The table a person reads. Already ranked: this lays out, it does not sort. */
|
|
46
|
+
export function renderSeenTable(ranked, since, everyone) {
|
|
47
|
+
const head = ["#", "PAGE", "VISITS", "P50", "P95", "SNAPSHOT", "SNAP P50", "NET P50", "HTML", "JS"];
|
|
48
|
+
const rows = ranked.map((page, index) => [
|
|
49
|
+
String(index + 1),
|
|
50
|
+
page.page,
|
|
51
|
+
String(page.visits),
|
|
52
|
+
ms(page.p50_ms),
|
|
53
|
+
ms(page.p95_ms),
|
|
54
|
+
percent(page.snapshot_share),
|
|
55
|
+
ms(page.snapshot_p50_ms),
|
|
56
|
+
ms(page.network_p50_ms),
|
|
57
|
+
size(page.html_bytes_p50),
|
|
58
|
+
size(page.js_bytes_p50),
|
|
59
|
+
]);
|
|
60
|
+
const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
61
|
+
const line = (cells) => cells.map((cell, column) => (column === 1 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ").trimEnd();
|
|
62
|
+
const who = everyone ? "everyone's visits" : "your visits";
|
|
63
|
+
if (ranked.length === 0) {
|
|
64
|
+
return [`No page views recorded in the last ${since} (${who}). Open Tower in a browser, then ask again.`];
|
|
65
|
+
}
|
|
66
|
+
return [
|
|
67
|
+
line(head),
|
|
68
|
+
...rows.map(line),
|
|
69
|
+
"",
|
|
70
|
+
`${who}, last ${since}. Times are from the moment real content first showed (the last-known screen or the fresh one) `
|
|
71
|
+
+ "to the press on the link (soft) or the start of the load (hard). P50/P95 are nearest-rank. SNAPSHOT is the share "
|
|
72
|
+
+ "painted from the last-known screen. HTML and JS are median transfer sizes where the browser reported them (a cached copy reports none).",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
export async function runSpeedSeen(command, io) {
|
|
76
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
77
|
+
const session = await loadPairedSession("speed", command.homeDir);
|
|
78
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
79
|
+
const since = command.since ?? SEEN_DEFAULT_SINCE;
|
|
80
|
+
const everyone = command.everyone === true;
|
|
81
|
+
const query = new URLSearchParams({ since });
|
|
82
|
+
if (everyone)
|
|
83
|
+
query.set("everyone", "1");
|
|
84
|
+
const answer = await towerJsonRequest({
|
|
85
|
+
dashboardUrl,
|
|
86
|
+
path: `/api/speed/seen?${query.toString()}`,
|
|
87
|
+
method: "GET",
|
|
88
|
+
deviceToken: session.device_token,
|
|
89
|
+
fetch: io.fetch,
|
|
90
|
+
label: "speed-seen",
|
|
91
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
92
|
+
log: (line) => {
|
|
93
|
+
if (!line.startsWith("[tower client] responded"))
|
|
94
|
+
log(line);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
if (!answer.ok) {
|
|
98
|
+
const reason = towerFailureDetail(answer.reason, answer.detail);
|
|
99
|
+
log(`[speed cli] seen refused ${JSON.stringify({ since, everyone, reason: answer.reason, http_status: answer.httpStatus ?? null })}`);
|
|
100
|
+
if (command.json)
|
|
101
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, reason: answer.reason, message: reason }));
|
|
102
|
+
else
|
|
103
|
+
writeLine(io.stderr, `cockpit speed --seen: ${reason}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const body = (answer.body ?? {});
|
|
107
|
+
const wanted = command.pages;
|
|
108
|
+
const pages = rankSeen((body.pages ?? []).filter((page) => wanted.length === 0 || wanted.includes(page.page)));
|
|
109
|
+
const visits = pages.reduce((sum, page) => sum + page.visits, 0);
|
|
110
|
+
log(`[speed cli] seen read ${JSON.stringify({ since, everyone, pages: pages.length, visits })}`);
|
|
111
|
+
if (command.json) {
|
|
112
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, since, everyone, dashboard_url: dashboardUrl, pages }));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
for (const line of renderSeenTable(pages, since, everyone))
|
|
116
|
+
writeLine(io.stdout, line);
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit speed` (BLI-13033) — how long each Tower rail page's data door takes,
|
|
3
|
+
* measured from this terminal with this machine's own device token.
|
|
4
|
+
*
|
|
5
|
+
* For every page: one request per run, the first reported as cold and the rest
|
|
6
|
+
* as warm, then p50, p95, the HTTP status, the response size and the door's
|
|
7
|
+
* own `Server-Timing` header. A page this account may not open (Careers for a
|
|
8
|
+
* member) reads "not permitted", which is an answer, not a failure.
|
|
9
|
+
*
|
|
10
|
+
* What a number is: wall-clock from sending the request to the last byte of the
|
|
11
|
+
* body, on THIS machine, so it includes the network between here and Tower. It
|
|
12
|
+
* is the data door, not the page's HTML or paint (`speed-pages.ts` says which
|
|
13
|
+
* door stands for which page). Reads only, and never the local read cache or
|
|
14
|
+
* the warm-connection route: a speed number that a cache answered would be a
|
|
15
|
+
* lie about Tower.
|
|
16
|
+
*
|
|
17
|
+
* stdout is `--json`'s alone; every log line goes to stderr, metadata only (a
|
|
18
|
+
* page name, a status, a millisecond count). The device token is attached by
|
|
19
|
+
* the shared Tower client and never printed.
|
|
20
|
+
*/
|
|
21
|
+
import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
|
|
22
|
+
import { writeLine } from "./cli-io.js";
|
|
23
|
+
import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
|
|
24
|
+
import { runSpeedSeen } from "./speed-seen.js";
|
|
25
|
+
import { speedPages } from "./speed-pages.js";
|
|
26
|
+
import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
|
|
27
|
+
export const DEFAULT_SPEED_RUNS = 5;
|
|
28
|
+
const REQUEST_DEADLINE_MS = 60_000;
|
|
29
|
+
export async function runSpeed(command, io, deps = {}) {
|
|
30
|
+
// BLI-13054: `--seen` reads real visits (speed-seen.ts) instead of timing the data doors.
|
|
31
|
+
if (command.seen)
|
|
32
|
+
return runSpeedSeen(command, io);
|
|
33
|
+
const now = deps.now ?? (() => performance.now());
|
|
34
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
35
|
+
const session = await loadPairedSession("speed", command.homeDir);
|
|
36
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
37
|
+
const runs = command.runs ?? DEFAULT_SPEED_RUNS;
|
|
38
|
+
const everyPage = speedPages(resolveContainerTag({ cwd: process.cwd() }).containerTag);
|
|
39
|
+
const chosen = command.pages.length > 0
|
|
40
|
+
? everyPage.filter((page) => command.pages.includes(page.name))
|
|
41
|
+
: everyPage;
|
|
42
|
+
const results = [];
|
|
43
|
+
for (const page of chosen) {
|
|
44
|
+
const samples = [];
|
|
45
|
+
for (let run = 0; run < runs; run += 1) {
|
|
46
|
+
const sample = await timeOneRead(page, { dashboardUrl, deviceToken: session.device_token, io, now, log });
|
|
47
|
+
samples.push(sample);
|
|
48
|
+
// A page this account may not open answers the same way every time.
|
|
49
|
+
if (classifyStatus(sample.status) === "not_permitted")
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
const summary = summarizeSamples(page, samples);
|
|
53
|
+
results.push(summary);
|
|
54
|
+
log(`[speed cli] page measured ${JSON.stringify({
|
|
55
|
+
page: summary.page,
|
|
56
|
+
outcome: summary.outcome,
|
|
57
|
+
http_status: summary.http_status,
|
|
58
|
+
runs: samples.length,
|
|
59
|
+
failed_runs: summary.failed_runs,
|
|
60
|
+
p50_ms: summary.p50_ms,
|
|
61
|
+
...(summary.reason ? { reason: summary.reason } : {}),
|
|
62
|
+
})}`);
|
|
63
|
+
}
|
|
64
|
+
const ranked = rankPages(results);
|
|
65
|
+
const failed = ranked.filter((page) => page.outcome === "failed").length;
|
|
66
|
+
const notPermitted = ranked.filter((page) => page.outcome === "not_permitted").length;
|
|
67
|
+
log(`[speed cli] done ${JSON.stringify({
|
|
68
|
+
pages: ranked.length,
|
|
69
|
+
measured: ranked.length - failed - notPermitted,
|
|
70
|
+
failed,
|
|
71
|
+
not_permitted: notPermitted,
|
|
72
|
+
runs,
|
|
73
|
+
})}`);
|
|
74
|
+
if (command.json) {
|
|
75
|
+
writeLine(io.stdout, JSON.stringify({
|
|
76
|
+
ok: failed === 0,
|
|
77
|
+
measured_at: new Date().toISOString(),
|
|
78
|
+
dashboard_url: dashboardUrl,
|
|
79
|
+
runs,
|
|
80
|
+
pages: ranked,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const line of renderSpeedTable(ranked, runs))
|
|
85
|
+
writeLine(io.stdout, line);
|
|
86
|
+
}
|
|
87
|
+
return failed === 0 ? 0 : 1;
|
|
88
|
+
}
|
|
89
|
+
async function timeOneRead(page, context) {
|
|
90
|
+
const startedAt = context.now();
|
|
91
|
+
const answer = await towerRequest({
|
|
92
|
+
dashboardUrl: context.dashboardUrl,
|
|
93
|
+
path: page.path,
|
|
94
|
+
method: "GET",
|
|
95
|
+
deviceToken: context.deviceToken,
|
|
96
|
+
fetch: context.io.fetch,
|
|
97
|
+
label: `speed-${page.name}`,
|
|
98
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
99
|
+
// The client's per-request success trace would print 70 lines here; the
|
|
100
|
+
// failure lines stay.
|
|
101
|
+
log: (line) => {
|
|
102
|
+
if (!line.startsWith("[tower client] responded"))
|
|
103
|
+
context.log(line);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
if (!answer.ok) {
|
|
107
|
+
return {
|
|
108
|
+
status: null,
|
|
109
|
+
ms: context.now() - startedAt,
|
|
110
|
+
bytes: 0,
|
|
111
|
+
serverTiming: null,
|
|
112
|
+
failure: `${answer.reason}: ${towerFailureDetail(answer.reason, answer.detail)}`.slice(0, 120),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
let bytes = 0;
|
|
116
|
+
try {
|
|
117
|
+
bytes = (await answer.response.arrayBuffer()).byteLength;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return {
|
|
121
|
+
status: answer.response.status,
|
|
122
|
+
ms: context.now() - startedAt,
|
|
123
|
+
bytes: 0,
|
|
124
|
+
serverTiming: null,
|
|
125
|
+
failure: "body_read_failed",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
status: answer.response.status,
|
|
130
|
+
ms: context.now() - startedAt,
|
|
131
|
+
bytes,
|
|
132
|
+
serverTiming: answer.response.headers.get("server-timing"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which commits a session made (BLI-13028).
|
|
3
|
+
*
|
|
4
|
+
* The Setup page asks "did this session's work ship in a merged pull request?".
|
|
5
|
+
* A session's branch answers that for only a minority of sessions (most sit on
|
|
6
|
+
* `main`), but the commits a session made always can: git remembers, in the
|
|
7
|
+
* working tree's own reflog, every commit created there and when. So the
|
|
8
|
+
* collector reads that reflog once per working tree and hands each session the
|
|
9
|
+
* commits created between its first and last transcript timestamps.
|
|
10
|
+
*
|
|
11
|
+
* Why the reflog and not `git log`: the reflog is per working tree, so a
|
|
12
|
+
* session in one worktree is never credited with a commit another worktree made
|
|
13
|
+
* at the same moment (a log of the shared history would be). It also holds the
|
|
14
|
+
* new SHAs a rebase writes, which are the ones the pull request ends up with.
|
|
15
|
+
*
|
|
16
|
+
* Metadata only: full commit SHAs, never messages, authors or diffs. One git
|
|
17
|
+
* call per working tree per sync, not per session, because a first run
|
|
18
|
+
* revisits thousands of sessions and a Windows `git` start costs ~100 ms.
|
|
19
|
+
* `execFile` with an argument array runs the same on macOS and native Windows
|
|
20
|
+
* (no shell, no quoting).
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { describeError } from "./health-detail.js";
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
/** Newest reflog entries read per working tree. A busy tree makes a few hundred a day. */
|
|
27
|
+
const REFLOG_ENTRY_LIMIT = 20_000;
|
|
28
|
+
/** A commit made just after the last transcript line still belongs to the session that asked for it. */
|
|
29
|
+
export const SESSION_COMMIT_GRACE_MS = 120_000;
|
|
30
|
+
/** Same cap the wire schema enforces. */
|
|
31
|
+
export const SESSION_COMMIT_SHA_LIMIT = 100;
|
|
32
|
+
/** Reflog subjects that mean "this working tree just created this commit". */
|
|
33
|
+
const COMMIT_CREATING_SUBJECT = /^(commit\b|cherry-pick\b|revert\b|rebase(?: -i)? \((?:pick|reword|squash|fixup|continue|edit)\))/;
|
|
34
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
35
|
+
export const runGitForCommits = async (args, cwd) => {
|
|
36
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
37
|
+
cwd,
|
|
38
|
+
timeout: 20_000,
|
|
39
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
42
|
+
});
|
|
43
|
+
return stdout;
|
|
44
|
+
};
|
|
45
|
+
/** Every commit-creating reflog entry of one working tree, or the reason it could not be read. */
|
|
46
|
+
export async function readCreatedCommits(repoRoot, git = runGitForCommits) {
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = await git(["log", "-g", "--date=unix", `-n${REFLOG_ENTRY_LIMIT}`, "--format=%H%x1f%gd%x1f%gs", "HEAD"], repoRoot);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error("[session-commits] reflog unreadable", JSON.stringify({ reason: "git_reflog_failed", ...describeError(error) }));
|
|
53
|
+
return { failure: "git_reflog_failed" };
|
|
54
|
+
}
|
|
55
|
+
return { commits: parseCreatedCommits(raw) };
|
|
56
|
+
}
|
|
57
|
+
export function parseCreatedCommits(raw) {
|
|
58
|
+
const commits = [];
|
|
59
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
60
|
+
const [sha, selector, subject] = line.split("\x1f");
|
|
61
|
+
if (!sha || !FULL_SHA.test(sha) || !subject || !COMMIT_CREATING_SUBJECT.test(subject))
|
|
62
|
+
continue;
|
|
63
|
+
const at = Number(/@\{(\d+)\}$/.exec(selector ?? "")?.[1]);
|
|
64
|
+
if (Number.isFinite(at))
|
|
65
|
+
commits.push({ sha, at });
|
|
66
|
+
}
|
|
67
|
+
return commits;
|
|
68
|
+
}
|
|
69
|
+
/** The commits created in `[startMs, endMs + grace]`, newest first, capped. */
|
|
70
|
+
export function commitsInSpan(commits, startMs, endMs) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const commit of commits) {
|
|
73
|
+
const atMs = commit.at * 1000;
|
|
74
|
+
if (atMs >= startMs && atMs <= endMs + SESSION_COMMIT_GRACE_MS)
|
|
75
|
+
seen.add(commit.sha);
|
|
76
|
+
}
|
|
77
|
+
return [...seen].slice(0, SESSION_COMMIT_SHA_LIMIT);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Commit SHAs per session, keyed `${source}:${session_id}`. A session with no
|
|
81
|
+
* working tree, or no transcript timestamps, gets no entry: "unknown", not
|
|
82
|
+
* "made no commits". A session whose tree was read and made none gets `[]`.
|
|
83
|
+
*/
|
|
84
|
+
export async function collectSessionCommits(results, git = runGitForCommits) {
|
|
85
|
+
const ledgers = new Map();
|
|
86
|
+
const bySession = new Map();
|
|
87
|
+
const counts = { sessions_with_commits: 0, sessions_without_commits: 0, sessions_unknown: 0, trees_read: 0, trees_failed: 0 };
|
|
88
|
+
for (const { key, result } of results) {
|
|
89
|
+
const start = Date.parse(result.session_facts?.first_timestamp ?? "");
|
|
90
|
+
const end = Date.parse(result.session_facts?.last_timestamp ?? "");
|
|
91
|
+
if (!result.worktree || !Number.isFinite(start) || !Number.isFinite(end)) {
|
|
92
|
+
counts.sessions_unknown += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const root = result.worktree.repo_root;
|
|
96
|
+
if (!ledgers.has(root))
|
|
97
|
+
ledgers.set(root, readCreatedCommits(root, git));
|
|
98
|
+
const ledger = await ledgers.get(root);
|
|
99
|
+
if ("failure" in ledger) {
|
|
100
|
+
counts.sessions_unknown += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const shas = commitsInSpan(ledger.commits, start, end);
|
|
104
|
+
bySession.set(key, [...new Set([...(bySession.get(key) ?? []), ...shas])].slice(0, SESSION_COMMIT_SHA_LIMIT));
|
|
105
|
+
counts[shas.length > 0 ? "sessions_with_commits" : "sessions_without_commits"] += 1;
|
|
106
|
+
}
|
|
107
|
+
for (const ledger of ledgers.values())
|
|
108
|
+
counts["failure" in (await ledger) ? "trees_failed" : "trees_read"] += 1;
|
|
109
|
+
console.error("[session-commits] commits recorded per session", JSON.stringify({ reason: "session_commits_collected", ...counts }));
|
|
110
|
+
return bySession;
|
|
111
|
+
}
|
|
112
|
+
/** The wire field, only when the collector actually looked. */
|
|
113
|
+
export function commitShasField(shas) {
|
|
114
|
+
return shas ? { commit_shas: shas } : {};
|
|
115
|
+
}
|
|
116
|
+
/** Stamp each report row with the commits recorded for its session. */
|
|
117
|
+
export function withSessionCommits(rows, commitsBySession) {
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
...row,
|
|
120
|
+
...commitShasField(commitsBySession.get(`${row.source}:${row.codex_session_id}`)),
|
|
121
|
+
}));
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.195",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
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-cli-doctor-json.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.58",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.107",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.67"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|