@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 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 commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg} --json || exit_code=1`);
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.
@@ -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
  }
@@ -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
- const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
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: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
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
- const reasons = result.incomplete_reasons.join(",");
1798
- if (io) {
1799
- writeLine(io.stderr, `BLOCKED: repo discovery is incomplete (${reasons}); no collection cursor was advanced.`);
1800
- }
1801
- throw new Error(`repo discovery incomplete: ${reasons}. Raise --max-depth or --max-repos until every approved-root repo is included.`);
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.10");
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
+ }
@@ -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 ?? 50);
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 ?? 2;
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
- const entries = await fs.readdir(current.dir, { withFileTypes: true }).catch(() => []);
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 ?? 50);
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.10",
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.15"
29
+ "@bli-cockpit/telemetry-core": "0.1.16"
30
30
  }
31
31
  }