@bli-cockpit/cli 0.2.10 → 0.2.12
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/autostart.js +11 -2
- package/dist/commands/backfill.js +1 -3
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/local.js +92 -12
- package/dist/commands/public-root.js +1 -1
- package/dist/discovery-limits.js +89 -0
- package/dist/repo-identity.js +59 -4
- package/package.json +2 -2
package/dist/autostart.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
|
+
import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
|
|
5
6
|
/** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
|
|
6
7
|
export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
7
8
|
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
@@ -53,6 +54,7 @@ export async function installAutostartAgent(options) {
|
|
|
53
54
|
await mkdir(path.dirname(plistPath), { recursive: true });
|
|
54
55
|
await mkdir(paths.state_dir, { recursive: true });
|
|
55
56
|
await writeFile(plistPath, renderPlist({
|
|
57
|
+
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
56
58
|
workDir,
|
|
57
59
|
workDirs: resolvedWorkDirs,
|
|
58
60
|
dashboardUrl,
|
|
@@ -127,6 +129,7 @@ export async function autostartStatus(options) {
|
|
|
127
129
|
const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
128
130
|
const plist = await readFile(plistPath, "utf8").catch(() => "");
|
|
129
131
|
const registrationProblems = darwinAgentRegistrationProblems(plist, {
|
|
132
|
+
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
130
133
|
workDirs: resolvedWorkDirs,
|
|
131
134
|
dashboardUrl,
|
|
132
135
|
intervalSeconds,
|
|
@@ -165,6 +168,7 @@ async function installWindowsTask(options) {
|
|
|
165
168
|
const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
166
169
|
await mkdir(path.dirname(scriptPath), { recursive: true });
|
|
167
170
|
await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
|
|
171
|
+
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
168
172
|
workDirs: resolvedWorkDirs,
|
|
169
173
|
dashboardUrl,
|
|
170
174
|
nodeExecutable,
|
|
@@ -290,6 +294,7 @@ async function windowsTaskStatus(options) {
|
|
|
290
294
|
}
|
|
291
295
|
else if (options.repoRoots && options.repoRoots.length > 0) {
|
|
292
296
|
const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
|
|
297
|
+
discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
|
|
293
298
|
workDirs: normalizeWindowsWorkDirs(options.repoRoots),
|
|
294
299
|
dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
|
|
295
300
|
nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
|
|
@@ -337,9 +342,10 @@ function renderWindowsSyncScript(options) {
|
|
|
337
342
|
const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
338
343
|
? ""
|
|
339
344
|
: ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
|
|
345
|
+
const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
340
346
|
const commands = options.workDirs.flatMap((root) => [
|
|
341
347
|
"try {",
|
|
342
|
-
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs} --json`,
|
|
348
|
+
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json`,
|
|
343
349
|
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
344
350
|
"} catch {",
|
|
345
351
|
" [Console]::Error.WriteLine($_.Exception.Message)",
|
|
@@ -532,6 +538,7 @@ function renderPlist(options) {
|
|
|
532
538
|
dashboardUrl: options.dashboardUrl,
|
|
533
539
|
nodeExecutable: options.nodeExecutable,
|
|
534
540
|
cliEntryPoint: options.cliEntryPoint,
|
|
541
|
+
discoveryArgs: options.discoveryArgs,
|
|
535
542
|
});
|
|
536
543
|
return [
|
|
537
544
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -569,7 +576,8 @@ function renderDarwinSyncCommand(options) {
|
|
|
569
576
|
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
570
577
|
? ""
|
|
571
578
|
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
572
|
-
const
|
|
579
|
+
const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
580
|
+
const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
|
|
573
581
|
return ["exit_code=0", ...commands, 'exit "$exit_code"'].join("; ");
|
|
574
582
|
}
|
|
575
583
|
function darwinAgentRegistrationProblems(plist, expected) {
|
|
@@ -583,6 +591,7 @@ function darwinAgentRegistrationProblems(plist, expected) {
|
|
|
583
591
|
problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
|
|
584
592
|
}
|
|
585
593
|
const expectedCommand = renderDarwinSyncCommand({
|
|
594
|
+
discoveryArgs: expected.discoveryArgs,
|
|
586
595
|
workDirs: expected.workDirs,
|
|
587
596
|
dashboardUrl: expected.dashboardUrl,
|
|
588
597
|
nodeExecutable: expected.nodeExecutable,
|
|
@@ -9,7 +9,7 @@ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET }
|
|
|
9
9
|
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
10
10
|
import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
11
11
|
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
|
|
12
|
-
import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
12
|
+
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
13
13
|
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
14
14
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
15
15
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
@@ -17,8 +17,6 @@ import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelo
|
|
|
17
17
|
const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
|
|
18
18
|
const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
19
19
|
const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
|
|
20
|
-
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
21
|
-
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
22
20
|
// Window a bare `cockpit backfill` uses. Wide enough to cover a new machine's
|
|
23
21
|
// recent history and an intern who went quiet for a few weeks, narrow enough
|
|
24
22
|
// that it is not the whole-history scan `--all` deliberately gates.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { autostartStatus, installAutostartAgent } from "../autostart.js";
|
|
4
|
+
import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
|
|
4
5
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
5
6
|
import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
6
7
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
@@ -314,8 +315,12 @@ async function fixSyncState(context) {
|
|
|
314
315
|
if (roots.length === 0) {
|
|
315
316
|
return fail("sync-fresh", "no_roots", "sync has no saved workspace roots");
|
|
316
317
|
}
|
|
318
|
+
// The remembered limits have to ride along, or the doctor's own verification
|
|
319
|
+
// sync scans differently from every other run and can fail closed on a
|
|
320
|
+
// machine the operator already fixed by hand (BLI-2362).
|
|
321
|
+
const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
|
|
317
322
|
for (const repoRoot of roots) {
|
|
318
|
-
const args = ["sync", "--json", "--workspace", repoRoot];
|
|
323
|
+
const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
|
|
319
324
|
if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
|
|
320
325
|
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
321
326
|
}
|
package/dist/commands/local.js
CHANGED
|
@@ -16,6 +16,7 @@ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.j
|
|
|
16
16
|
import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
17
17
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
18
18
|
import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
19
|
+
import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
|
|
19
20
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
20
21
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
21
22
|
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
@@ -58,6 +59,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
58
59
|
writeLine(io.stderr, localCommandHelp());
|
|
59
60
|
return 1;
|
|
60
61
|
}
|
|
62
|
+
// A limit typed on the command line is remembered for every later run,
|
|
63
|
+
// including the scheduled one nobody types into (BLI-2362). Done here so it
|
|
64
|
+
// applies to whichever command carried the flag.
|
|
65
|
+
await rememberDiscoveryLimits(command);
|
|
61
66
|
try {
|
|
62
67
|
switch (command.kind) {
|
|
63
68
|
case "install":
|
|
@@ -1430,6 +1435,7 @@ async function runOnboard(command, io) {
|
|
|
1430
1435
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
1431
1436
|
maxDepth: command.maxDepth,
|
|
1432
1437
|
maxRepos: command.maxRepos,
|
|
1438
|
+
homeDir: command.homeDir,
|
|
1433
1439
|
allowEmpty: true,
|
|
1434
1440
|
}, io);
|
|
1435
1441
|
if (worktrees.length > 1) {
|
|
@@ -1779,32 +1785,105 @@ function rawEvidenceSyncLine(sync) {
|
|
|
1779
1785
|
function cursorStatusLine(sync) {
|
|
1780
1786
|
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
1781
1787
|
}
|
|
1782
|
-
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
1783
|
-
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
1784
1788
|
const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
|
|
1785
1789
|
const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
|
|
1786
1790
|
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
1787
|
-
|
|
1791
|
+
// What the operator typed this run, else what they typed some previous run,
|
|
1792
|
+
// else the built-in defaults (BLI-2362).
|
|
1793
|
+
const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
|
|
1794
|
+
const maxWorktrees = limits.maxRepos;
|
|
1788
1795
|
const roots = Array.isArray(repoRoot)
|
|
1789
1796
|
? repoRoot
|
|
1790
1797
|
: [repoRoot ?? process.cwd()];
|
|
1791
1798
|
const result = await discoverGitWorktreesInRootsWithStatus(roots, {
|
|
1792
|
-
maxDepth:
|
|
1799
|
+
maxDepth: limits.maxDepth,
|
|
1793
1800
|
maxWorktrees,
|
|
1794
1801
|
});
|
|
1802
|
+
if (io && result.unreadable_dirs.length > 0) {
|
|
1803
|
+
// Never silently dropped: anything under these folders is missing from the
|
|
1804
|
+
// scan, so say so even when the run otherwise succeeds.
|
|
1805
|
+
writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
|
|
1806
|
+
}
|
|
1795
1807
|
const worktrees = result.worktrees;
|
|
1796
1808
|
if (!result.complete) {
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1809
|
+
// Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
|
|
1810
|
+
// cursor after a partial scan would mark the run as covering repos it
|
|
1811
|
+
// never saw, permanently skipping their sessions — backfill can tolerate
|
|
1812
|
+
// partial only because it keeps per-scope completion markers, and sync
|
|
1813
|
+
// does not. The bug (BLI-2362) was that the refusal named no roots and
|
|
1814
|
+
// gave no runnable command, so a big workspace just stayed red forever.
|
|
1815
|
+
const message = incompleteDiscoveryMessage({
|
|
1816
|
+
result,
|
|
1817
|
+
roots,
|
|
1818
|
+
maxDepth: limits.maxDepth,
|
|
1819
|
+
maxRepos: maxWorktrees,
|
|
1820
|
+
found: worktrees.length,
|
|
1821
|
+
});
|
|
1822
|
+
if (io)
|
|
1823
|
+
writeLine(io.stderr, message);
|
|
1824
|
+
throw new Error(message);
|
|
1802
1825
|
}
|
|
1803
1826
|
if (worktrees.length === 0 && !discovery.allowEmpty) {
|
|
1804
1827
|
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
1805
1828
|
}
|
|
1806
1829
|
return worktrees;
|
|
1807
1830
|
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Persists `--max-depth` / `--max-repos` when a command carried them, so the
|
|
1833
|
+
* number survives into the background sync and the doctor's own sync — neither
|
|
1834
|
+
* of which has anywhere to type one (BLI-2362). Best-effort: failing to record
|
|
1835
|
+
* a preference must never fail the command the operator actually asked for.
|
|
1836
|
+
*/
|
|
1837
|
+
async function rememberDiscoveryLimits(command) {
|
|
1838
|
+
const limits = command;
|
|
1839
|
+
if (limits.maxDepth === undefined && limits.maxRepos === undefined)
|
|
1840
|
+
return;
|
|
1841
|
+
await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Says which folders could not be opened, and therefore what the scan could not
|
|
1845
|
+
* see. Reported without failing the run — an unreadable folder cannot be fixed
|
|
1846
|
+
* by retrying, so blocking on one would strand the machine (BLI-2362).
|
|
1847
|
+
*/
|
|
1848
|
+
function unreadableDirectoriesMessage(unreadable) {
|
|
1849
|
+
return [
|
|
1850
|
+
`WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
|
|
1851
|
+
...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
|
|
1852
|
+
"Collection continued for everything else. If a repo is missing from Cockpit,",
|
|
1853
|
+
"check the permissions on the folders above.",
|
|
1854
|
+
].join("\n");
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Names the roots that could not be covered and hands back a command that
|
|
1858
|
+
* actually fixes it, with this machine's numbers already filled in.
|
|
1859
|
+
*/
|
|
1860
|
+
function incompleteDiscoveryMessage(input) {
|
|
1861
|
+
const { result, roots, maxDepth, maxRepos, found } = input;
|
|
1862
|
+
const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
|
|
1863
|
+
const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
|
|
1864
|
+
const nextDepth = maxDepth + 3;
|
|
1865
|
+
const nextRepos = Math.max(maxRepos * 2, found + 50);
|
|
1866
|
+
const retry = [
|
|
1867
|
+
"cockpit do-everything",
|
|
1868
|
+
...roots.map((root) => `--workspace ${root}`),
|
|
1869
|
+
`--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
|
|
1870
|
+
`--max-repos ${nextRepos}`,
|
|
1871
|
+
].join(" ");
|
|
1872
|
+
return [
|
|
1873
|
+
`Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
|
|
1874
|
+
"It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
|
|
1875
|
+
"",
|
|
1876
|
+
"Could not fully scan:",
|
|
1877
|
+
...blocked.map((root) => ` ${root}`),
|
|
1878
|
+
"",
|
|
1879
|
+
`Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
|
|
1880
|
+
"",
|
|
1881
|
+
"Run this to raise the limits and try again:",
|
|
1882
|
+
` ${retry}`,
|
|
1883
|
+
"",
|
|
1884
|
+
"If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
|
|
1885
|
+
].join("\n");
|
|
1886
|
+
}
|
|
1808
1887
|
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
1809
1888
|
if (!command.json) {
|
|
1810
1889
|
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
@@ -1999,7 +2078,7 @@ async function runLogout(command, io) {
|
|
|
1999
2078
|
return 0;
|
|
2000
2079
|
}
|
|
2001
2080
|
async function runStart(command, io) {
|
|
2002
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
2081
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
2003
2082
|
if (worktrees.length > 1) {
|
|
2004
2083
|
const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
|
|
2005
2084
|
homeDir: command.homeDir,
|
|
@@ -2190,6 +2269,7 @@ async function runSyncLocked(command, io) {
|
|
|
2190
2269
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
2191
2270
|
maxDepth: command.maxDepth,
|
|
2192
2271
|
maxRepos: command.maxRepos,
|
|
2272
|
+
homeDir: command.homeDir,
|
|
2193
2273
|
allowEmpty: true,
|
|
2194
2274
|
}, io);
|
|
2195
2275
|
const run = await runAttributedWorktreeSync({
|
|
@@ -2432,7 +2512,7 @@ async function runSyncRawEvidenceGc(command, io) {
|
|
|
2432
2512
|
}
|
|
2433
2513
|
async function runStatus(command, io) {
|
|
2434
2514
|
const backfillCursor = await inspectBackfillCursor(command.homeDir);
|
|
2435
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
2515
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
2436
2516
|
if (worktrees.length > 1) {
|
|
2437
2517
|
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
2438
2518
|
...(await inspectLocalCollectorStatus({
|
|
@@ -2663,7 +2743,7 @@ function sessionsWindowLine(window) {
|
|
|
2663
2743
|
async function runSessions(command, io) {
|
|
2664
2744
|
const now = new Date();
|
|
2665
2745
|
const homeDir = command.homeDir ?? os.homedir();
|
|
2666
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
2746
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
2667
2747
|
const window = await sessionsScanWindow(command, now);
|
|
2668
2748
|
const wantCodex = command.source !== "claude";
|
|
2669
2749
|
const wantClaude = command.source !== "codex";
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.12");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "./repo-identity.js";
|
|
5
|
+
import { getCollectorRuntimePaths } from "./local-state.js";
|
|
6
|
+
const SCHEMA_VERSION = "cockpit-discovery-limits.v1";
|
|
7
|
+
// A depth beyond this is a typo rather than a workspace, and an unbounded walk
|
|
8
|
+
// on a huge tree is its own outage. Same for the repo cap.
|
|
9
|
+
const MAX_ALLOWED_DEPTH = 64;
|
|
10
|
+
const MAX_ALLOWED_REPOS = 5000;
|
|
11
|
+
function discoveryLimitsFile(homeDir) {
|
|
12
|
+
return path.join(getCollectorRuntimePaths(homeDir).state_dir, "discovery-limits.json");
|
|
13
|
+
}
|
|
14
|
+
function sanitizeLimit(value, ceiling) {
|
|
15
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
16
|
+
return undefined;
|
|
17
|
+
if (value < 1 || value > ceiling)
|
|
18
|
+
return undefined;
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reads the remembered limits. Never throws: a corrupt file falls back to the
|
|
23
|
+
* defaults rather than taking collection down, because failing to read a tuning
|
|
24
|
+
* knob is not a reason to stop collecting.
|
|
25
|
+
*/
|
|
26
|
+
export async function readSavedDiscoveryLimits(homeDir = os.homedir()) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await fs.readFile(discoveryLimitsFile(homeDir), "utf8");
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return {
|
|
31
|
+
max_depth: sanitizeLimit(parsed.max_depth, MAX_ALLOWED_DEPTH),
|
|
32
|
+
max_repos: sanitizeLimit(parsed.max_repos, MAX_ALLOWED_REPOS),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Remembers limits an operator passed explicitly. Only writes the ones actually
|
|
41
|
+
* supplied, so raising the depth does not quietly reset a repo cap someone set
|
|
42
|
+
* earlier for their own reasons.
|
|
43
|
+
*/
|
|
44
|
+
export async function saveDiscoveryLimits(limits, homeDir = os.homedir()) {
|
|
45
|
+
const maxDepth = sanitizeLimit(limits.maxDepth, MAX_ALLOWED_DEPTH);
|
|
46
|
+
const maxRepos = sanitizeLimit(limits.maxRepos, MAX_ALLOWED_REPOS);
|
|
47
|
+
if (maxDepth === undefined && maxRepos === undefined)
|
|
48
|
+
return;
|
|
49
|
+
const existing = await readSavedDiscoveryLimits(homeDir);
|
|
50
|
+
const next = {
|
|
51
|
+
schema_version: SCHEMA_VERSION,
|
|
52
|
+
max_depth: maxDepth ?? existing.max_depth,
|
|
53
|
+
max_repos: maxRepos ?? existing.max_repos,
|
|
54
|
+
updated_at: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
const file = discoveryLimitsFile(homeDir);
|
|
57
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
58
|
+
await fs.writeFile(file, `${JSON.stringify(next, null, 2)}\n`, {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
mode: 0o600,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The limits a scan should actually use: what the operator typed this run,
|
|
65
|
+
* else what they typed some previous run, else the built-in defaults.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveDiscoveryLimits(command, homeDir = os.homedir()) {
|
|
68
|
+
const saved = await readSavedDiscoveryLimits(homeDir);
|
|
69
|
+
return {
|
|
70
|
+
maxDepth: command.maxDepth ?? saved.max_depth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
71
|
+
maxRepos: command.maxRepos ?? saved.max_repos ?? DEFAULT_DISCOVERY_MAX_REPOS,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The flags a generated background command needs so a scheduled run scans the
|
|
76
|
+
* same way an operator's manual run did. Empty when the machine is on the
|
|
77
|
+
* defaults, which keeps the common plist/Task Scheduler command unchanged.
|
|
78
|
+
*/
|
|
79
|
+
export async function savedDiscoveryLimitArgs(homeDir = os.homedir()) {
|
|
80
|
+
const saved = await readSavedDiscoveryLimits(homeDir);
|
|
81
|
+
const args = [];
|
|
82
|
+
if (saved.max_depth !== undefined) {
|
|
83
|
+
args.push("--max-depth", String(saved.max_depth));
|
|
84
|
+
}
|
|
85
|
+
if (saved.max_repos !== undefined) {
|
|
86
|
+
args.push("--max-repos", String(saved.max_repos));
|
|
87
|
+
}
|
|
88
|
+
return args;
|
|
89
|
+
}
|
package/dist/repo-identity.js
CHANGED
|
@@ -16,6 +16,27 @@ const SKIPPED_DIR_NAMES = new Set([
|
|
|
16
16
|
"node_modules",
|
|
17
17
|
"out",
|
|
18
18
|
]);
|
|
19
|
+
/**
|
|
20
|
+
* How deep discovery walks, and how many repos it will hold, when the caller
|
|
21
|
+
* names no limit. Both are the ONLY defaults — commands import these rather
|
|
22
|
+
* than declaring their own, because a second copy silently drifts.
|
|
23
|
+
*
|
|
24
|
+
* These are generous on purpose. Discovery stops descending the moment it sees
|
|
25
|
+
* a git marker and skips dot-dirs, `node_modules`, `dist` and friends, so the
|
|
26
|
+
* walk is bounded by plain folders rather than by repo contents: on a real
|
|
27
|
+
* workspace of 38 repos, depth 20 visited the same 308 directories in the same
|
|
28
|
+
* ~110ms as depth 6, because the tree simply ran out first. A limit that is too
|
|
29
|
+
* low is not merely slow — discovery fails closed, so every extra level costs
|
|
30
|
+
* nothing while every missing level costs the whole machine's collection.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_DISCOVERY_MAX_DEPTH = 20;
|
|
33
|
+
export const DEFAULT_DISCOVERY_MAX_REPOS = 200;
|
|
34
|
+
/** Caps the reported list so one pathological tree cannot dominate a receipt. */
|
|
35
|
+
const MAX_REPORTED_UNREADABLE_DIRS = 25;
|
|
36
|
+
function errorCode(error) {
|
|
37
|
+
const code = error?.code;
|
|
38
|
+
return typeof code === "string" ? code : "UNKNOWN";
|
|
39
|
+
}
|
|
19
40
|
export async function resolveRepoWorktreeIdentity(repoRoot) {
|
|
20
41
|
const requestedPath = path.resolve(repoRoot);
|
|
21
42
|
const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
|
|
@@ -62,18 +83,19 @@ export async function discoverGitWorktrees(root, options = {}) {
|
|
|
62
83
|
export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
63
84
|
const resolvedRoot = path.resolve(root);
|
|
64
85
|
const allowedRoots = [await stableWorktreeRoot(resolvedRoot)];
|
|
65
|
-
const maxWorktrees = Math.max(1, options.maxWorktrees ??
|
|
86
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
|
|
66
87
|
const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
67
88
|
if (direct)
|
|
68
89
|
return expandLinkedWorktrees([direct], maxWorktrees, allowedRoots);
|
|
69
90
|
if (await hasGitMarker(resolvedRoot)) {
|
|
70
91
|
return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees, allowedRoots);
|
|
71
92
|
}
|
|
72
|
-
const maxDepth = options.maxDepth ??
|
|
93
|
+
const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH;
|
|
73
94
|
const discovered = new Map();
|
|
74
95
|
const stack = [{ dir: resolvedRoot, depth: 0 }];
|
|
75
96
|
const visited = new Set();
|
|
76
97
|
const incompleteReasons = new Set();
|
|
98
|
+
const unreadableDirs = [];
|
|
77
99
|
while (stack.length > 0) {
|
|
78
100
|
if (discovered.size >= maxWorktrees) {
|
|
79
101
|
incompleteReasons.add("max_worktrees_reached");
|
|
@@ -92,7 +114,18 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
|
92
114
|
discovered.set(identity.worktree_fingerprint, identity);
|
|
93
115
|
continue;
|
|
94
116
|
}
|
|
95
|
-
|
|
117
|
+
// A failed read is recorded, never swallowed: anything beneath this folder
|
|
118
|
+
// is missing from the scan, and the operator has to be able to see that.
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (unreadableDirs.length < MAX_REPORTED_UNREADABLE_DIRS) {
|
|
125
|
+
unreadableDirs.push({ path: current.dir, code: errorCode(error) });
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
96
129
|
if (current.depth >= maxDepth) {
|
|
97
130
|
if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
|
|
98
131
|
incompleteReasons.add("max_depth_reached");
|
|
@@ -113,6 +146,9 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
|
113
146
|
worktrees: expanded.worktrees,
|
|
114
147
|
complete: incompleteReasons.size === 0,
|
|
115
148
|
incomplete_reasons: [...incompleteReasons].sort(),
|
|
149
|
+
// Single-root scanner: the only root in play is the one it was handed.
|
|
150
|
+
incomplete_roots: incompleteReasons.size === 0 ? [] : [path.resolve(root)],
|
|
151
|
+
unreadable_dirs: unreadableDirs,
|
|
116
152
|
};
|
|
117
153
|
}
|
|
118
154
|
/**
|
|
@@ -126,9 +162,11 @@ export async function discoverGitWorktreesInRoots(roots, options = {}) {
|
|
|
126
162
|
return (await discoverGitWorktreesInRootsWithStatus(roots, options)).worktrees;
|
|
127
163
|
}
|
|
128
164
|
export async function discoverGitWorktreesInRootsWithStatus(roots, options = {}) {
|
|
129
|
-
const maxWorktrees = Math.max(1, options.maxWorktrees ??
|
|
165
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
|
|
130
166
|
const discovered = new Map();
|
|
131
167
|
const incompleteReasons = new Set();
|
|
168
|
+
const incompleteRoots = new Set();
|
|
169
|
+
const unreadableDirs = [];
|
|
132
170
|
for (const root of roots) {
|
|
133
171
|
const result = await discoverGitWorktreesWithStatus(root, {
|
|
134
172
|
...options,
|
|
@@ -136,11 +174,20 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
|
|
|
136
174
|
});
|
|
137
175
|
for (const reason of result.incomplete_reasons) {
|
|
138
176
|
incompleteReasons.add(reason);
|
|
177
|
+
incompleteRoots.add(root);
|
|
178
|
+
}
|
|
179
|
+
for (const dir of result.unreadable_dirs) {
|
|
180
|
+
if (unreadableDirs.length >= MAX_REPORTED_UNREADABLE_DIRS)
|
|
181
|
+
break;
|
|
182
|
+
unreadableDirs.push(dir);
|
|
139
183
|
}
|
|
140
184
|
for (const worktree of result.worktrees) {
|
|
141
185
|
if (discovered.size >= maxWorktrees) {
|
|
142
186
|
if (!discovered.has(worktree.worktree_fingerprint)) {
|
|
143
187
|
incompleteReasons.add("max_worktrees_reached");
|
|
188
|
+
// The cap is global, so the root being read when it filled up is the
|
|
189
|
+
// one whose repos got dropped.
|
|
190
|
+
incompleteRoots.add(root);
|
|
144
191
|
}
|
|
145
192
|
continue;
|
|
146
193
|
}
|
|
@@ -151,6 +198,8 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
|
|
|
151
198
|
worktrees: [...discovered.values()].sort(compareIdentity),
|
|
152
199
|
complete: incompleteReasons.size === 0,
|
|
153
200
|
incomplete_reasons: [...incompleteReasons].sort(),
|
|
201
|
+
incomplete_roots: [...incompleteRoots].sort(),
|
|
202
|
+
unreadable_dirs: unreadableDirs,
|
|
154
203
|
};
|
|
155
204
|
}
|
|
156
205
|
/**
|
|
@@ -219,6 +268,12 @@ async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
|
|
|
219
268
|
worktrees,
|
|
220
269
|
complete: incompleteReasons.length === 0,
|
|
221
270
|
incomplete_reasons: incompleteReasons,
|
|
271
|
+
// Expansion asks git for its own worktree list; it never walks folders, so
|
|
272
|
+
// it has no unreadable directories of its own to report.
|
|
273
|
+
unreadable_dirs: [],
|
|
274
|
+
// Linked-worktree expansion is not scoped to one root; callers merge this
|
|
275
|
+
// into a result that already knows which roots were involved.
|
|
276
|
+
incomplete_roots: [],
|
|
222
277
|
};
|
|
223
278
|
}
|
|
224
279
|
function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.16"
|
|
30
30
|
}
|
|
31
31
|
}
|