@bli-cockpit/cli 0.2.9 → 0.2.11
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/backfill.js +20 -20
- package/dist/commands/local-args.js +54 -2
- package/dist/commands/local.js +175 -11
- package/dist/commands/public-root.js +14 -2
- package/dist/onboarding-roots.js +13 -2
- package/dist/repo-identity.js +29 -3
- package/dist/spool/install-event-outbox.js +8 -0
- package/package.json +2 -2
|
@@ -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,21 +17,21 @@ 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
|
-
|
|
21
|
-
|
|
20
|
+
// Window a bare `cockpit backfill` uses. Wide enough to cover a new machine's
|
|
21
|
+
// recent history and an intern who went quiet for a few weeks, narrow enough
|
|
22
|
+
// that it is not the whole-history scan `--all` deliberately gates.
|
|
23
|
+
export const DEFAULT_BACKFILL_SINCE_DAYS = 30;
|
|
22
24
|
export async function runBackfillCommand(command, io) {
|
|
25
|
+
// A bare `cockpit backfill` used to refuse and print three lines telling the
|
|
26
|
+
// operator to pick a window. That put a mandatory flag on the command that
|
|
27
|
+
// matters most for coverage, so the honest default is to run the common case
|
|
28
|
+
// and say plainly which window was chosen. `--all` stays explicit because it
|
|
29
|
+
// is the expensive, whole-history scan.
|
|
30
|
+
let effective = command;
|
|
23
31
|
if (!command.all && command.sinceDays === undefined) {
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
writeLine(io.stderr,
|
|
27
|
-
if (command.json) {
|
|
28
|
-
writeLine(io.stdout, JSON.stringify({
|
|
29
|
-
status: "blocked",
|
|
30
|
-
reason: "missing_window",
|
|
31
|
-
retry_command: retryCommand,
|
|
32
|
-
}, null, 2));
|
|
33
|
-
}
|
|
34
|
-
return 1;
|
|
32
|
+
effective = { ...command, sinceDays: DEFAULT_BACKFILL_SINCE_DAYS };
|
|
33
|
+
const notice = defaultBackfillWindowNotice();
|
|
34
|
+
writeLine(command.json ? io.stderr : io.stdout, notice);
|
|
35
35
|
}
|
|
36
36
|
if (command.all && !command.yes && !isInteractiveStdin(io)) {
|
|
37
37
|
const message = "--all requires TTY confirmation; pass --yes for agent runs.";
|
|
@@ -46,8 +46,8 @@ export async function runBackfillCommand(command, io) {
|
|
|
46
46
|
}
|
|
47
47
|
return 1;
|
|
48
48
|
}
|
|
49
|
-
const result = await runBackfill(
|
|
50
|
-
if (
|
|
49
|
+
const result = await runBackfill(effective, io);
|
|
50
|
+
if (effective.json) {
|
|
51
51
|
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
52
52
|
return result.status === "complete" ? 0 : 1;
|
|
53
53
|
}
|
|
@@ -508,11 +508,11 @@ export async function runBackfill(command, io) {
|
|
|
508
508
|
await lock.handle.release();
|
|
509
509
|
}
|
|
510
510
|
}
|
|
511
|
-
function
|
|
511
|
+
function defaultBackfillWindowNotice() {
|
|
512
512
|
return [
|
|
513
|
-
|
|
514
|
-
"
|
|
515
|
-
"Use `cockpit backfill --all`
|
|
513
|
+
`No window given — backfilling the last ${DEFAULT_BACKFILL_SINCE_DAYS} days.`,
|
|
514
|
+
"The effective start is capped at the collector paired_at timestamp.",
|
|
515
|
+
"Use `cockpit backfill --since-days N` for a different window, or `cockpit backfill --all` for the full local history (review a dry-run first; add `--yes` on headless agent runs).",
|
|
516
516
|
].join("\n");
|
|
517
517
|
}
|
|
518
518
|
/**
|
|
@@ -7,6 +7,18 @@ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
|
|
|
7
7
|
import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
|
|
8
8
|
import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
|
|
9
9
|
const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
|
|
10
|
+
// The six human "set my machine up" doors. They are one thing wearing six
|
|
11
|
+
// hats, so they all run the convergence command — but they keep accepting the
|
|
12
|
+
// flags they always accepted, because DMs, runbooks and AGENTS.md rules across
|
|
13
|
+
// the fleet still spell them out.
|
|
14
|
+
export const DOCTOR_SETUP_ALIASES = [
|
|
15
|
+
"install",
|
|
16
|
+
"onboard",
|
|
17
|
+
"login",
|
|
18
|
+
"pair",
|
|
19
|
+
"update",
|
|
20
|
+
"upgrade",
|
|
21
|
+
];
|
|
10
22
|
export function parseLocalArgs(argv) {
|
|
11
23
|
const command = argv[0];
|
|
12
24
|
switch (command) {
|
|
@@ -15,6 +27,10 @@ export function parseLocalArgs(argv) {
|
|
|
15
27
|
case "update":
|
|
16
28
|
case "upgrade":
|
|
17
29
|
return parseUpdateArgs(command, argv.slice(1));
|
|
30
|
+
// NOT yet aliased to the convergence command. `onboard --ticket <id>` binds
|
|
31
|
+
// work to a ticket, and routing it here would accept the flag and drop the
|
|
32
|
+
// binding with no error — the silent breakage BLI-2490 explicitly forbids.
|
|
33
|
+
// Aliasing lands once the convergence run honours --ticket. See BLI-2494.
|
|
18
34
|
case "do-everything":
|
|
19
35
|
case "fix":
|
|
20
36
|
return parseDoctorArgs(command, argv.slice(1));
|
|
@@ -114,15 +130,46 @@ function parseUpdateArgs(alias, args) {
|
|
|
114
130
|
};
|
|
115
131
|
}
|
|
116
132
|
function parseDoctorArgs(alias, args) {
|
|
133
|
+
// The allowed set is the UNION of what the six setup doors used to accept.
|
|
134
|
+
// An alias that silently rejected a flag its own docs told people to pass
|
|
135
|
+
// would be a worse dead end than the one we are removing.
|
|
117
136
|
const values = parseNamedArgs(args, {
|
|
118
137
|
allowedFlags: [
|
|
138
|
+
"--home",
|
|
139
|
+
"--repo",
|
|
119
140
|
"--workspace",
|
|
120
141
|
"--dashboard-url",
|
|
121
142
|
"--update-tag",
|
|
122
143
|
"--dry-run",
|
|
123
144
|
"--json",
|
|
145
|
+
"--allow-home-root",
|
|
146
|
+
"--max-depth",
|
|
147
|
+
"--max-repos",
|
|
148
|
+
// Accepted and ignored: the convergence run works these out itself.
|
|
149
|
+
// Rejecting them would break existing DMs and runbooks for no gain.
|
|
150
|
+
"--email",
|
|
151
|
+
"--device-name",
|
|
152
|
+
"--ticket",
|
|
153
|
+
"--branch",
|
|
154
|
+
"--no-auth",
|
|
155
|
+
"--poll-interval-ms",
|
|
156
|
+
"--timeout-ms",
|
|
157
|
+
],
|
|
158
|
+
valueFlags: [
|
|
159
|
+
"--home",
|
|
160
|
+
"--repo",
|
|
161
|
+
"--workspace",
|
|
162
|
+
"--dashboard-url",
|
|
163
|
+
"--update-tag",
|
|
164
|
+
"--max-depth",
|
|
165
|
+
"--max-repos",
|
|
166
|
+
"--email",
|
|
167
|
+
"--device-name",
|
|
168
|
+
"--ticket",
|
|
169
|
+
"--branch",
|
|
170
|
+
"--poll-interval-ms",
|
|
171
|
+
"--timeout-ms",
|
|
124
172
|
],
|
|
125
|
-
valueFlags: ["--workspace", "--dashboard-url", "--update-tag"],
|
|
126
173
|
});
|
|
127
174
|
assertNoPositionals(values.positionals, alias);
|
|
128
175
|
const updateTag = optionalNonEmpty(values.flags.get("--update-tag"));
|
|
@@ -132,11 +179,16 @@ function parseDoctorArgs(alias, args) {
|
|
|
132
179
|
return {
|
|
133
180
|
kind: "doctor",
|
|
134
181
|
alias,
|
|
135
|
-
|
|
182
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
183
|
+
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
184
|
+
collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
|
|
136
185
|
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
137
186
|
updateTag,
|
|
138
187
|
dryRun: values.booleans.has("--dry-run"),
|
|
139
188
|
json: values.booleans.has("--json"),
|
|
189
|
+
allowHomeRoot: values.booleans.has("--allow-home-root"),
|
|
190
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
191
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
140
192
|
};
|
|
141
193
|
}
|
|
142
194
|
function parseInstallArgs(args) {
|
package/dist/commands/local.js
CHANGED
|
@@ -10,11 +10,12 @@ import { inspectBackfillLock } from "../backfill-lock.js";
|
|
|
10
10
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
11
11
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
12
12
|
import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
13
|
+
import { redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
13
14
|
import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
14
15
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
15
16
|
import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
16
17
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
17
|
-
import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
18
|
+
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
18
19
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
19
20
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
20
21
|
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
@@ -367,6 +368,9 @@ async function runInstall(command, io) {
|
|
|
367
368
|
addInstallEvent(installEvents, "home_root_optin", "ok");
|
|
368
369
|
}
|
|
369
370
|
const result = await installLocalCollector(resolved.command);
|
|
371
|
+
// Same invariant as the onboarding path: never report a successful install
|
|
372
|
+
// over a config that saved no usable collection root.
|
|
373
|
+
await assertCollectionRootPersisted(resolved.command.homeDir);
|
|
370
374
|
addInstallEvent(installEvents, "install", "ok");
|
|
371
375
|
if (command.json) {
|
|
372
376
|
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
@@ -707,6 +711,15 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
707
711
|
...(event.error_code
|
|
708
712
|
? { error_code: sanitizeInstallErrorCode(event.error_code) }
|
|
709
713
|
: {}),
|
|
714
|
+
// Already redacted and capped at the point it was produced; bounded
|
|
715
|
+
// again here because this mapping is what the server contract sees.
|
|
716
|
+
...(event.error_detail
|
|
717
|
+
? {
|
|
718
|
+
error_detail: event.error_detail
|
|
719
|
+
.trim()
|
|
720
|
+
.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
|
|
721
|
+
}
|
|
722
|
+
: {}),
|
|
710
723
|
...(event.at ? { at: event.at } : {}),
|
|
711
724
|
})),
|
|
712
725
|
});
|
|
@@ -1199,7 +1212,7 @@ async function resolveOnboardingRootsForCommand(command, io) {
|
|
|
1199
1212
|
};
|
|
1200
1213
|
}
|
|
1201
1214
|
async function persistOnboardingRootConfig(command, resolution) {
|
|
1202
|
-
|
|
1215
|
+
const result = await installLocalCollector({
|
|
1203
1216
|
homeDir: command.homeDir,
|
|
1204
1217
|
repoRoot: resolution.primaryRoot,
|
|
1205
1218
|
repoRoots: resolution.collectionRoots,
|
|
@@ -1207,6 +1220,88 @@ async function persistOnboardingRootConfig(command, resolution) {
|
|
|
1207
1220
|
dashboardUrl: command.dashboardUrl,
|
|
1208
1221
|
deviceName: command.deviceName,
|
|
1209
1222
|
});
|
|
1223
|
+
await assertCollectionRootPersisted(command.homeDir);
|
|
1224
|
+
return result;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Setup does not get to claim success on its own say-so.
|
|
1228
|
+
*
|
|
1229
|
+
* Onboarding used to write the config and report success without ever reading
|
|
1230
|
+
* it back. Savina's onboard did exactly that, persisted nothing, and every
|
|
1231
|
+
* scheduled sync afterwards threw `collection_root_required` into a log nobody
|
|
1232
|
+
* reads — twelve consecutive failures, six days at 3 uploaded of 195, found
|
|
1233
|
+
* only by hand-querying the database. BLI-1986 fixed one path into that state;
|
|
1234
|
+
* this closes the state itself.
|
|
1235
|
+
*
|
|
1236
|
+
* So we read the config back through the SAME resolution the scheduled sync
|
|
1237
|
+
* will use, and fail here — in front of a human who can still fix it — rather
|
|
1238
|
+
* than silently handing back a machine that will never collect.
|
|
1239
|
+
*/
|
|
1240
|
+
export async function assertCollectionRootPersisted(homeDir) {
|
|
1241
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
1242
|
+
const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
1243
|
+
if (saved.length === 0) {
|
|
1244
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
|
|
1245
|
+
}
|
|
1246
|
+
// Present in the file is not the same as usable. A root that no longer
|
|
1247
|
+
// exists on disk resolves to nothing at sync time, which is the same silent
|
|
1248
|
+
// dead end arriving one step later.
|
|
1249
|
+
const usable = [];
|
|
1250
|
+
for (const root of saved) {
|
|
1251
|
+
if (await directoryExists(root))
|
|
1252
|
+
usable.push(root);
|
|
1253
|
+
}
|
|
1254
|
+
if (usable.length === 0) {
|
|
1255
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
|
|
1256
|
+
}
|
|
1257
|
+
return usable;
|
|
1258
|
+
}
|
|
1259
|
+
// Placeholders like <path-to-your-work-folder> make a person stop and think.
|
|
1260
|
+
// These print real, paste-able commands with this machine's actual paths in
|
|
1261
|
+
// them, so the fix is a copy away rather than a puzzle.
|
|
1262
|
+
function collectionRootNotPersistedMessage(homeDir) {
|
|
1263
|
+
const home = path.resolve(homeDir ?? os.homedir());
|
|
1264
|
+
return [
|
|
1265
|
+
"Setup finished without saving a collection root, so this machine would never collect anything.",
|
|
1266
|
+
"Nothing was saved, so nothing is broken — setup just did not finish.",
|
|
1267
|
+
"",
|
|
1268
|
+
"Fix it by running ONE of these:",
|
|
1269
|
+
"",
|
|
1270
|
+
" # Sync everything on this machine (what most people want on a work laptop)",
|
|
1271
|
+
" cockpit do-everything --allow-home-root",
|
|
1272
|
+
"",
|
|
1273
|
+
" # Or sync one folder — replace the path with where your projects live",
|
|
1274
|
+
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
1275
|
+
"",
|
|
1276
|
+
" # Or answer the folder question interactively",
|
|
1277
|
+
" cockpit do-everything",
|
|
1278
|
+
"",
|
|
1279
|
+
"Then check it worked:",
|
|
1280
|
+
" cockpit status",
|
|
1281
|
+
].join("\n");
|
|
1282
|
+
}
|
|
1283
|
+
function collectionRootMissingOnDiskMessage(saved, homeDir) {
|
|
1284
|
+
const home = path.resolve(homeDir ?? os.homedir());
|
|
1285
|
+
return [
|
|
1286
|
+
"Cockpit is set up to collect from a folder that is not on this machine:",
|
|
1287
|
+
...saved.map((root) => ` ${root}`),
|
|
1288
|
+
"",
|
|
1289
|
+
"That usually means the folder was renamed, moved, or deleted since setup.",
|
|
1290
|
+
"",
|
|
1291
|
+
"Fix it by running ONE of these:",
|
|
1292
|
+
"",
|
|
1293
|
+
" # Point Cockpit at where your projects actually live now",
|
|
1294
|
+
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
1295
|
+
"",
|
|
1296
|
+
" # Or sync everything on this machine and stop worrying about the path",
|
|
1297
|
+
" cockpit do-everything --allow-home-root",
|
|
1298
|
+
"",
|
|
1299
|
+
"Not sure where your projects are? This lists the folders Cockpit can see:",
|
|
1300
|
+
" cockpit status",
|
|
1301
|
+
].join("\n");
|
|
1302
|
+
}
|
|
1303
|
+
async function directoryExists(dir) {
|
|
1304
|
+
return stat(dir).then((stats) => stats.isDirectory(), () => false);
|
|
1210
1305
|
}
|
|
1211
1306
|
async function runDoctorLogin(command, io) {
|
|
1212
1307
|
return runLogin({
|
|
@@ -1218,9 +1313,15 @@ async function runDoctorLogin(command, io) {
|
|
|
1218
1313
|
}
|
|
1219
1314
|
async function resolveAndSaveDoctorRoots(command, io) {
|
|
1220
1315
|
const rootCommand = {
|
|
1316
|
+
homeDir: command.homeDir,
|
|
1221
1317
|
repoRoot: command.repoRoot,
|
|
1318
|
+
collectionRoots: command.collectionRoots,
|
|
1222
1319
|
dashboardUrl: command.dashboardUrl,
|
|
1223
1320
|
json: command.json,
|
|
1321
|
+
// Interactively this is not needed — the convergence run reaches the
|
|
1322
|
+
// home-folder consent prompt and Enter accepts it. The flag is the
|
|
1323
|
+
// headless equivalent for scripted and scheduled runs.
|
|
1324
|
+
allowHomeRoot: command.allowHomeRoot,
|
|
1224
1325
|
};
|
|
1225
1326
|
const resolution = await resolveOnboardingRootsForCommand(rootCommand, io);
|
|
1226
1327
|
await persistOnboardingRootConfig(rootCommand, resolution);
|
|
@@ -1678,8 +1779,6 @@ function rawEvidenceSyncLine(sync) {
|
|
|
1678
1779
|
function cursorStatusLine(sync) {
|
|
1679
1780
|
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
1680
1781
|
}
|
|
1681
|
-
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
1682
|
-
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
1683
1782
|
const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
|
|
1684
1783
|
const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
|
|
1685
1784
|
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
@@ -1693,17 +1792,59 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
|
1693
1792
|
});
|
|
1694
1793
|
const worktrees = result.worktrees;
|
|
1695
1794
|
if (!result.complete) {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1795
|
+
// Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
|
|
1796
|
+
// cursor after a partial scan would mark the run as covering repos it
|
|
1797
|
+
// never saw, permanently skipping their sessions — backfill can tolerate
|
|
1798
|
+
// partial only because it keeps per-scope completion markers, and sync
|
|
1799
|
+
// does not. The bug (BLI-2362) was that the refusal named no roots and
|
|
1800
|
+
// gave no runnable command, so a big workspace just stayed red forever.
|
|
1801
|
+
const message = incompleteDiscoveryMessage({
|
|
1802
|
+
result,
|
|
1803
|
+
roots,
|
|
1804
|
+
maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
1805
|
+
maxRepos: maxWorktrees,
|
|
1806
|
+
found: worktrees.length,
|
|
1807
|
+
});
|
|
1808
|
+
if (io)
|
|
1809
|
+
writeLine(io.stderr, message);
|
|
1810
|
+
throw new Error(message);
|
|
1701
1811
|
}
|
|
1702
1812
|
if (worktrees.length === 0 && !discovery.allowEmpty) {
|
|
1703
1813
|
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
1704
1814
|
}
|
|
1705
1815
|
return worktrees;
|
|
1706
1816
|
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Names the roots that could not be covered and hands back a command that
|
|
1819
|
+
* actually fixes it, with this machine's numbers already filled in.
|
|
1820
|
+
*/
|
|
1821
|
+
function incompleteDiscoveryMessage(input) {
|
|
1822
|
+
const { result, roots, maxDepth, maxRepos, found } = input;
|
|
1823
|
+
const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
|
|
1824
|
+
const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
|
|
1825
|
+
const nextDepth = maxDepth + 3;
|
|
1826
|
+
const nextRepos = Math.max(maxRepos * 2, found + 50);
|
|
1827
|
+
const retry = [
|
|
1828
|
+
"cockpit do-everything",
|
|
1829
|
+
...roots.map((root) => `--workspace ${root}`),
|
|
1830
|
+
`--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
|
|
1831
|
+
`--max-repos ${nextRepos}`,
|
|
1832
|
+
].join(" ");
|
|
1833
|
+
return [
|
|
1834
|
+
`Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
|
|
1835
|
+
"It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
|
|
1836
|
+
"",
|
|
1837
|
+
"Could not fully scan:",
|
|
1838
|
+
...blocked.map((root) => ` ${root}`),
|
|
1839
|
+
"",
|
|
1840
|
+
`Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
|
|
1841
|
+
"",
|
|
1842
|
+
"Run this to raise the limits and try again:",
|
|
1843
|
+
` ${retry}`,
|
|
1844
|
+
"",
|
|
1845
|
+
"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.",
|
|
1846
|
+
].join("\n");
|
|
1847
|
+
}
|
|
1707
1848
|
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
1708
1849
|
if (!command.json) {
|
|
1709
1850
|
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
@@ -1974,6 +2115,7 @@ async function runSync(command, io) {
|
|
|
1974
2115
|
step: "sync_complete",
|
|
1975
2116
|
status: "fail",
|
|
1976
2117
|
error_code: classifySyncHealthError(error),
|
|
2118
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
1977
2119
|
},
|
|
1978
2120
|
],
|
|
1979
2121
|
json: command.json,
|
|
@@ -2048,7 +2190,7 @@ async function runSyncWithHealthReceipt(command, io) {
|
|
|
2048
2190
|
await lock.handle.release();
|
|
2049
2191
|
}
|
|
2050
2192
|
}
|
|
2051
|
-
function classifySyncHealthError(error) {
|
|
2193
|
+
export function classifySyncHealthError(error) {
|
|
2052
2194
|
const message = errorMessage(error);
|
|
2053
2195
|
if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
|
|
2054
2196
|
return "auth_failed";
|
|
@@ -2056,11 +2198,33 @@ function classifySyncHealthError(error) {
|
|
|
2056
2198
|
if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
|
|
2057
2199
|
return "network_failed";
|
|
2058
2200
|
}
|
|
2059
|
-
|
|
2201
|
+
// Anchored on the code the collector actually throws rather than on loose
|
|
2202
|
+
// vocabulary. The old test matched /collection.root|workspace|repo|worktree/
|
|
2203
|
+
// against the message, so any failure that merely mentioned a repo was filed
|
|
2204
|
+
// as a collection-root failure and the real reason was lost (BLI-2492).
|
|
2205
|
+
if (message.includes(COLLECTION_ROOT_REQUIRED) ||
|
|
2206
|
+
/collection root/iu.test(message)) {
|
|
2060
2207
|
return "collection_root_failed";
|
|
2061
2208
|
}
|
|
2062
2209
|
return "sync_failed";
|
|
2063
2210
|
}
|
|
2211
|
+
// The bucket above is for aggregation. This is the reason — the actual message,
|
|
2212
|
+
// redacted on the machine that produced it, before it ever leaves.
|
|
2213
|
+
//
|
|
2214
|
+
// Error text can carry absolute paths and, on some auth failures, token-shaped
|
|
2215
|
+
// fragments. It goes through the same deterministic redaction the collector
|
|
2216
|
+
// already applies to evidence, and is capped so one pathological stack trace
|
|
2217
|
+
// cannot dominate a health receipt.
|
|
2218
|
+
export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
|
|
2219
|
+
export function redactedSyncErrorDetail(error) {
|
|
2220
|
+
const message = errorMessage(error).replace(/\s+/gu, " ").trim();
|
|
2221
|
+
const { text } = redactSecretLikeContent(message, {
|
|
2222
|
+
appliedBy: "local_collector",
|
|
2223
|
+
});
|
|
2224
|
+
return text.length > SYNC_ERROR_DETAIL_MAX_CHARS
|
|
2225
|
+
? `${text.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
|
|
2226
|
+
: text;
|
|
2227
|
+
}
|
|
2064
2228
|
async function runSyncLocked(command, io) {
|
|
2065
2229
|
const collectionRoots = await resolveSyncCollectionRoots(command);
|
|
2066
2230
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
@@ -2,13 +2,20 @@ import { localCommandHelp, runLocalCockpitCli, rootCommandNames } from "./local.
|
|
|
2
2
|
|
|
3
3
|
export async function runCockpitCli(argv, io) {
|
|
4
4
|
const command = argv[0];
|
|
5
|
-
|
|
5
|
+
// Bare `cockpit` runs the convergence command instead of printing a
|
|
6
|
+
// usage wall (BLI-2490). This file is GENERATED and does not import
|
|
7
|
+
// commands/root.ts, so the routing has to be mirrored here or the fix
|
|
8
|
+
// ships to nobody.
|
|
9
|
+
if (!command) {
|
|
10
|
+
return runLocalCockpitCli(["do-everything"], io);
|
|
11
|
+
}
|
|
12
|
+
if (command === "--help" || command === "-h") {
|
|
6
13
|
writeLine(io?.stdout ?? process.stdout, cockpitHelp());
|
|
7
14
|
return 0;
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
11
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.11");
|
|
12
19
|
return 0;
|
|
13
20
|
}
|
|
14
21
|
|
|
@@ -24,6 +31,11 @@ export async function runCockpitCli(argv, io) {
|
|
|
24
31
|
|
|
25
32
|
function cockpitHelp() {
|
|
26
33
|
return [
|
|
34
|
+
"Just run `cockpit do-everything` — it takes this machine from nothing to collecting and asks you anything it needs.",
|
|
35
|
+
"(Running plain `cockpit` does the same thing.)",
|
|
36
|
+
"",
|
|
37
|
+
"Everything below is for scripting and agents.",
|
|
38
|
+
"",
|
|
27
39
|
"Usage:",
|
|
28
40
|
localCommandHelp(),
|
|
29
41
|
"",
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
|
6
6
|
export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
|
|
7
7
|
export function homeRootConsentPrompt(homeDirInput) {
|
|
8
8
|
const homeDir = path.resolve(homeDirInput ?? os.homedir());
|
|
9
|
-
return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed. [
|
|
9
|
+
return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed.\n Press Enter to sync everything, or answer n to name one folder instead. [Y/n]: `;
|
|
10
10
|
}
|
|
11
11
|
export const HOME_ROOT_DECLINE_PATH_PROMPT = "Okay — which folder should I sync? Enter the full path to your work directory: ";
|
|
12
12
|
export const HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE = "No folder chosen. Re-run: cockpit onboard --workspace <path-to-your-work-folder>";
|
|
@@ -223,9 +223,20 @@ async function promptForHomeRootOptIn(options, rejections) {
|
|
|
223
223
|
}
|
|
224
224
|
return promptForDeclinedHomeRoot(options, homeDir);
|
|
225
225
|
}
|
|
226
|
+
// Bare Enter is the answer most people give, so it has to mean the thing that
|
|
227
|
+
// collects. It used to mean decline, and the decline path then asks for a
|
|
228
|
+
// folder that is not home — which a person who keeps every project directly in
|
|
229
|
+
// their home folder cannot answer. They finished onboarding with zero roots and
|
|
230
|
+
// every session on the machine was legitimately out of scope forever after.
|
|
231
|
+
// Anything that isn't recognisably yes still routes to the decline prompt,
|
|
232
|
+
// where a typed path is recoverable rather than lost.
|
|
226
233
|
function isHomeRootYes(raw) {
|
|
227
234
|
const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
228
|
-
|
|
235
|
+
if (answer === "")
|
|
236
|
+
return true;
|
|
237
|
+
if (answer.startsWith("n"))
|
|
238
|
+
return false;
|
|
239
|
+
return answer.startsWith("y");
|
|
229
240
|
}
|
|
230
241
|
async function promptForDeclinedHomeRoot(options, homeDir) {
|
|
231
242
|
const prompt = requirePrompt(options);
|
package/dist/repo-identity.js
CHANGED
|
@@ -16,6 +16,21 @@ 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;
|
|
19
34
|
export async function resolveRepoWorktreeIdentity(repoRoot) {
|
|
20
35
|
const requestedPath = path.resolve(repoRoot);
|
|
21
36
|
const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
|
|
@@ -62,14 +77,14 @@ export async function discoverGitWorktrees(root, options = {}) {
|
|
|
62
77
|
export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
63
78
|
const resolvedRoot = path.resolve(root);
|
|
64
79
|
const allowedRoots = [await stableWorktreeRoot(resolvedRoot)];
|
|
65
|
-
const maxWorktrees = Math.max(1, options.maxWorktrees ??
|
|
80
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
|
|
66
81
|
const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
67
82
|
if (direct)
|
|
68
83
|
return expandLinkedWorktrees([direct], maxWorktrees, allowedRoots);
|
|
69
84
|
if (await hasGitMarker(resolvedRoot)) {
|
|
70
85
|
return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees, allowedRoots);
|
|
71
86
|
}
|
|
72
|
-
const maxDepth = options.maxDepth ??
|
|
87
|
+
const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH;
|
|
73
88
|
const discovered = new Map();
|
|
74
89
|
const stack = [{ dir: resolvedRoot, depth: 0 }];
|
|
75
90
|
const visited = new Set();
|
|
@@ -113,6 +128,8 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
|
113
128
|
worktrees: expanded.worktrees,
|
|
114
129
|
complete: incompleteReasons.size === 0,
|
|
115
130
|
incomplete_reasons: [...incompleteReasons].sort(),
|
|
131
|
+
// Single-root scanner: the only root in play is the one it was handed.
|
|
132
|
+
incomplete_roots: incompleteReasons.size === 0 ? [] : [path.resolve(root)],
|
|
116
133
|
};
|
|
117
134
|
}
|
|
118
135
|
/**
|
|
@@ -126,9 +143,10 @@ export async function discoverGitWorktreesInRoots(roots, options = {}) {
|
|
|
126
143
|
return (await discoverGitWorktreesInRootsWithStatus(roots, options)).worktrees;
|
|
127
144
|
}
|
|
128
145
|
export async function discoverGitWorktreesInRootsWithStatus(roots, options = {}) {
|
|
129
|
-
const maxWorktrees = Math.max(1, options.maxWorktrees ??
|
|
146
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
|
|
130
147
|
const discovered = new Map();
|
|
131
148
|
const incompleteReasons = new Set();
|
|
149
|
+
const incompleteRoots = new Set();
|
|
132
150
|
for (const root of roots) {
|
|
133
151
|
const result = await discoverGitWorktreesWithStatus(root, {
|
|
134
152
|
...options,
|
|
@@ -136,11 +154,15 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
|
|
|
136
154
|
});
|
|
137
155
|
for (const reason of result.incomplete_reasons) {
|
|
138
156
|
incompleteReasons.add(reason);
|
|
157
|
+
incompleteRoots.add(root);
|
|
139
158
|
}
|
|
140
159
|
for (const worktree of result.worktrees) {
|
|
141
160
|
if (discovered.size >= maxWorktrees) {
|
|
142
161
|
if (!discovered.has(worktree.worktree_fingerprint)) {
|
|
143
162
|
incompleteReasons.add("max_worktrees_reached");
|
|
163
|
+
// The cap is global, so the root being read when it filled up is the
|
|
164
|
+
// one whose repos got dropped.
|
|
165
|
+
incompleteRoots.add(root);
|
|
144
166
|
}
|
|
145
167
|
continue;
|
|
146
168
|
}
|
|
@@ -151,6 +173,7 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
|
|
|
151
173
|
worktrees: [...discovered.values()].sort(compareIdentity),
|
|
152
174
|
complete: incompleteReasons.size === 0,
|
|
153
175
|
incomplete_reasons: [...incompleteReasons].sort(),
|
|
176
|
+
incomplete_roots: [...incompleteRoots].sort(),
|
|
154
177
|
};
|
|
155
178
|
}
|
|
156
179
|
/**
|
|
@@ -219,6 +242,9 @@ async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
|
|
|
219
242
|
worktrees,
|
|
220
243
|
complete: incompleteReasons.length === 0,
|
|
221
244
|
incomplete_reasons: incompleteReasons,
|
|
245
|
+
// Linked-worktree expansion is not scoped to one root; callers merge this
|
|
246
|
+
// into a result that already knows which roots were involved.
|
|
247
|
+
incomplete_roots: [],
|
|
222
248
|
};
|
|
223
249
|
}
|
|
224
250
|
function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
|
|
@@ -23,6 +23,11 @@ export async function enqueueInstallEventEntry(paths, options) {
|
|
|
23
23
|
step: event.step,
|
|
24
24
|
status: event.status,
|
|
25
25
|
...(event.error_code ? { error_code: event.error_code } : {}),
|
|
26
|
+
// Spooling must not quietly downgrade a receipt. This mapping copies
|
|
27
|
+
// named fields, so a new one has to be added here too — otherwise a
|
|
28
|
+
// failure that could not be delivered immediately loses its reason on
|
|
29
|
+
// the way to disk and replays as a bare bucket (BLI-2492).
|
|
30
|
+
...(event.error_detail ? { error_detail: event.error_detail } : {}),
|
|
26
31
|
at: event.at ?? createdAt,
|
|
27
32
|
})),
|
|
28
33
|
};
|
|
@@ -157,6 +162,9 @@ function parseEvent(value) {
|
|
|
157
162
|
...(stringValue(record["error_code"])
|
|
158
163
|
? { error_code: stringValue(record["error_code"]) }
|
|
159
164
|
: {}),
|
|
165
|
+
...(stringValue(record["error_detail"])
|
|
166
|
+
? { error_detail: stringValue(record["error_detail"]) }
|
|
167
|
+
: {}),
|
|
160
168
|
at,
|
|
161
169
|
};
|
|
162
170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"pretypecheck": "npm run build",
|
|
24
24
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
25
25
|
"pretest": "npm run build",
|
|
26
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
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
29
|
"@bli-cockpit/telemetry-core": "0.1.15"
|