@bli-cockpit/cli 0.2.54 → 0.2.55

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.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -1,21 +1,8 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "../autostart.js";
4
- import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
5
- import { describeError, redactedHealthDetail } from "../health-detail.js";
6
- import { inspectBackfillLock } from "../backfill-lock.js";
7
- import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
8
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
9
- import { normalizeCollectionRoots } from "../root-normalization.js";
10
- import { detectSecondCockpitInstall } from "../second-install.js";
11
- import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
12
- import { runStagingPrune } from "../disk-prune.js";
13
- import { retentionOptionsFromEnv } from "../disk-retention.js";
14
- import { readDiskFootprint } from "../disk-usage.js";
15
- import { runBackfillCommand } from "./backfill.js";
16
- import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
17
- import { createInteractiveExecRunner } from "../process-runner.js";
18
- const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
1
+ import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
2
+ import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
3
+ import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
4
+ import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
5
+ import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
19
6
  export async function runDoctor(command, io, hooks, overrides = {}) {
20
7
  const deps = { ...defaultDoctorDeps(hooks), ...overrides };
21
8
  return runDoctorWithDeps(command, io, deps);
@@ -144,738 +131,6 @@ function defaultDoctorDeps(hooks) {
144
131
  fixSync: fixSyncState,
145
132
  };
146
133
  }
147
- async function checkCliLatest(context) {
148
- const latest = await context.deps.latestCliVersion(context);
149
- if (!latest) {
150
- return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
151
- }
152
- if (latest === LOCAL_COLLECTOR_VERSION) {
153
- return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
154
- }
155
- return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
156
- }
157
- async function fixCliLatest(context, state) {
158
- try {
159
- await context.deps.selfUpdate(context.io, {
160
- json: context.command.json,
161
- tag: context.command.updateTag,
162
- });
163
- }
164
- catch (error) {
165
- return fail("cli-latest", selfUpdateFailureCode(error), selfUpdateFailureMessage(error));
166
- }
167
- if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
168
- if (state.code === "stale_cli") {
169
- return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit do-everything`.");
170
- }
171
- return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
172
- }
173
- const code = await context.deps.reexecDoctor(context.command, context.io);
174
- return {
175
- ...ok("cli-latest", "reexeced", "self-update ran; re-execed the new cockpit binary."),
176
- reexecExitCode: code,
177
- };
178
- }
179
- async function fixAuthState(context, state) {
180
- const code = await context.deps.runLogin(context).catch((error) => {
181
- // Exit code 1 with no reason at all is what an operator saw when doctor
182
- // tried and failed to repair their auth — the same output as a login that
183
- // ran and was declined (BLI-3238).
184
- console.error("[cockpit-doctor] login repair threw", JSON.stringify({
185
- reason: "auth_repair_failed",
186
- ...describeError(error),
187
- }));
188
- return 1;
189
- });
190
- if (code !== 0)
191
- return state;
192
- const checked = await context.deps.readAuth(context);
193
- return checked.status === "ok" ? checked : state;
194
- }
195
- async function fixRootState(context, state) {
196
- await context.deps.resolveAndSaveRoots(context).catch((error) => {
197
- // Roots ARE the collection boundary. If saving them throws and nothing
198
- // says so, the machine converges to "no approved root" and collects
199
- // nothing — a green-looking doctor run over an empty boundary, which is
200
- // the failure mode this whole ticket exists for.
201
- console.error("[cockpit-doctor] collection roots could not be resolved or saved", JSON.stringify({
202
- reason: "roots_repair_failed",
203
- ...describeError(error),
204
- }));
205
- });
206
- const checked = await context.deps.readRoots(context);
207
- return checked.status === "ok" ? checked : state;
208
- }
209
- async function latestCliVersionFromNpm(context) {
210
- const exec = context.io.exec;
211
- if (!exec)
212
- return null;
213
- const packageSpec = context.command.updateTag
214
- ? `@bli-cockpit/cli@${context.command.updateTag}`
215
- : "@bli-cockpit/cli";
216
- const result = await exec("npm", ["view", packageSpec, "version", "--json"]);
217
- if (result.code !== 0)
218
- return null;
219
- return parseNpmVersion(result.stdout);
220
- }
221
- async function readAuthState(context) {
222
- const paths = getCollectorRuntimePaths();
223
- const session = await readLocalCollectorSessionFile(paths).catch(() => null);
224
- if (session?.session_state === "valid" &&
225
- typeof session.device_token === "string" &&
226
- session.device_token) {
227
- return ok("authed", "device_token_present", "this machine is signed in");
228
- }
229
- return hardStop("authed", "pairing_required", [
230
- "device is not signed in.",
231
- "What you can do:",
232
- " 1) Run `cockpit login` and complete the email/device approval.",
233
- ` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
234
- " 3) Send this output to Edward if approval is blocked.",
235
- ].join("\n"));
236
- }
237
- async function readRootState(context) {
238
- const paths = getCollectorRuntimePaths();
239
- const config = await readLocalCollectorConfig(paths).catch(() => null);
240
- const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
241
- if (roots.length > 0) {
242
- return {
243
- ...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
244
- roots,
245
- };
246
- }
247
- return hardStop("roots-ok", "no_roots", [
248
- "no saved collection roots were found.",
249
- "What you can do:",
250
- ` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
251
- " 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
252
- " 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
253
- ].join("\n"));
254
- }
255
- /**
256
- * BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
257
- * call the other's registration stale and re-register itself, so the plist
258
- * flaps every 15 minutes and the device reports two CLI versions the same
259
- * morning. Doctor OFFERS the removal and never performs it: an operator may
260
- * have a second prefix on purpose, and `npm uninstall -g` of something nobody
261
- * asked to remove is not a repair. The row therefore has no `fix` — it is
262
- * information, and doctor still exits 0.
263
- */
264
- async function checkSingleInstallState(context) {
265
- const exec = context.io.exec;
266
- if (!exec) {
267
- return skipped("single-install", "runner_unavailable", "could not look for a second Tower install");
268
- }
269
- const finding = await detectSecondCockpitInstall({ exec });
270
- if (!finding.detected) {
271
- return ok("single-install", finding.reason, finding.install_count === 1
272
- ? "one Tower install on this machine"
273
- : "no second Tower install found");
274
- }
275
- // The full paths go to the operator's own screen; redactedHealthDetail
276
- // masks them again before any of this is uploaded.
277
- return needsFix("single-install", "second_install_detected", [
278
- `${finding.install_count} Tower installs are on this machine's PATH:`,
279
- ...finding.paths.map((entry) => ` - ${entry}`),
280
- "Both will fight over the background scheduler, re-registering it against each other.",
281
- "Keep one. To remove the other:",
282
- ` npm uninstall -g @bli-cockpit/cli # run it with the node that owns ${finding.other_bin ?? "the other bin"}`,
283
- "Nothing was uninstalled — that is your call.",
284
- ].join("\n"));
285
- }
286
- async function checkAutostartState(context) {
287
- const exec = context.io.exec;
288
- if (!exec) {
289
- return needsFix("autostart-alive", "runner_unavailable", "autostart runner unavailable; would refresh autostart");
290
- }
291
- const roots = await doctorRoots(context);
292
- const result = await autostartStatus({
293
- repoRoot: roots[0],
294
- repoRoots: roots,
295
- dashboardUrl: context.command.dashboardUrl,
296
- exec,
297
- });
298
- if (result.status === "loaded") {
299
- // BLI-3553: "loaded" only means the scheduler accepted the registration.
300
- // It says nothing about whether the binary that registration names still
301
- // exists — and `brew upgrade node` deletes exactly that. Ask the
302
- // filesystem about the paths the PLATFORM holds, not the ones this process
303
- // happens to be running under.
304
- const missing = await registeredRuntimePathProblems({ exec });
305
- if (missing.length > 0) {
306
- return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
307
- }
308
- return ok("autostart-alive", "already_installed", "background sync is running");
309
- }
310
- if (result.status === "unsupported") {
311
- return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
312
- }
313
- return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
314
- }
315
- async function fixAutostartState(context) {
316
- const exec = context.io.exec;
317
- if (!exec) {
318
- return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
319
- }
320
- const roots = await savedRoots();
321
- const result = await installAutostartAgent({
322
- repoRoot: context.command.repoRoot ?? roots[0],
323
- repoRoots: context.command.repoRoot ? [context.command.repoRoot] : roots,
324
- dashboardUrl: context.command.dashboardUrl,
325
- exec,
326
- });
327
- if (result.status === "unsupported") {
328
- return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
329
- }
330
- if (result.loaded === false) {
331
- return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
332
- }
333
- return ok("autostart-alive", "installed", "background sync installed and running");
334
- }
335
- /**
336
- * BLI Memory's registration on this machine (BLI-3580).
337
- *
338
- * The step owns the CONFIG, never the software: it writes the MCP entry, the
339
- * hooks and the Codex table only when `bli-memory-mcp` actually resolves. With
340
- * no server it writes NOTHING and reads `skipped bin_missing` — registering
341
- * hooks that point at an absent binary would make every Claude Code turn print
342
- * a hook failure, which is worse than waiting a day.
343
- */
344
- async function checkMemoryState(context) {
345
- const outcome = await inspectMemoryIntegration(memoryCommandFor(context), context.io);
346
- return memoryStepState(outcome, "check");
347
- }
348
- async function fixMemoryState(context) {
349
- const outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
350
- return memoryStepState(outcome, "fix");
351
- }
352
- function memoryCommandFor(context) {
353
- return {
354
- kind: "memory",
355
- action: "install",
356
- homeDir: context.command.homeDir,
357
- dashboardUrl: context.command.dashboardUrl,
358
- dryRun: false,
359
- json: context.command.json,
360
- };
361
- }
362
- function memoryStepState(outcome, phase) {
363
- const broken = outcome.targets.filter((target) => target.target !== "bin" && target.status === "failed");
364
- if (broken.length > 0) {
365
- const first = broken[0];
366
- const message = `${first?.target ?? "memory"}: ${first?.reason ?? "failed"}`;
367
- return phase === "fix"
368
- ? fail("memory-registered", first?.reason ?? "memory_install_failed", message)
369
- : needsFix("memory-registered", first?.reason ?? "memory_install_failed", message);
370
- }
371
- const pending = outcome.targets.filter((target) => target.target !== "bin" &&
372
- (target.status === "missing" || target.status === "mismatch"));
373
- if (pending.length > 0) {
374
- return needsFix("memory-registered", "registration_incomplete", `BLI Memory is not registered with ${pending.map((target) => target.target).join(", ")}`);
375
- }
376
- if (!outcome.bin_found) {
377
- return skipped("memory-registered", "bin_missing", "bli-memory-mcp is not on this machine yet; nothing was written and the next run will try again");
378
- }
379
- return ok("memory-registered", phase === "fix" ? "installed" : "already_installed", "BLI Memory is registered with both agent hosts");
380
- }
381
- /**
382
- * Pure so it can be unit-tested without touching the real machine's home
383
- * directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
384
- * doctor never threads `--home` through the backfill steps). Returns `null`
385
- * when the marker does not cover the roots/sources — the caller falls
386
- * through to the lock/never-run diagnosis in that case.
387
- *
388
- * BLI-2727: a marker whose only outstanding entries are deterministic
389
- * oversized-file skips is still a completed backfill — it reads green with a
390
- * named note, never a red `needs_fix`/`fail`, so an unliftable file cap never
391
- * reads as "backfill never completed" on repeat doctor runs.
392
- */
393
- export function backfillCompletionStepState(marker, roots) {
394
- if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
395
- return null;
396
- }
397
- const oversized = marker?.oversized_skips;
398
- if (oversized && oversized.count > 0) {
399
- return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
400
- `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
401
- }
402
- return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
403
- }
404
- async function checkBackfillState(context) {
405
- const paths = getCollectorRuntimePaths();
406
- const roots = await doctorRoots(context);
407
- const marker = await readBackfillCompletionMarker(paths);
408
- const covered = backfillCompletionStepState(marker, roots);
409
- if (covered)
410
- return covered;
411
- const lock = await inspectBackfillLock(paths);
412
- if (lock.held) {
413
- return needsFix("backfill-complete", "backfill_already_running", `another catch-up run is still going, and this one has not finished yet (running since ${lock.held_since ?? "unknown"})`);
414
- }
415
- const cursor = await readBackfillCursor(paths);
416
- return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
417
- }
418
- async function fixBackfillState(context) {
419
- const capture = capturedIo(context.io, !context.command.json);
420
- const code = await runBackfillCommand({
421
- repoRoot: context.command.repoRoot,
422
- all: true,
423
- dryRun: false,
424
- yes: true,
425
- json: true,
426
- }, capture.io);
427
- const output = capture.stdout() + "\n" + capture.stderr();
428
- if (code === 0) {
429
- // Re-read the marker this run just wrote instead of hand-rolling a second
430
- // message: `checkBackfillState`'s pure core already knows how to say
431
- // "complete" vs "complete_with_oversized_skips" (BLI-2727), and this way
432
- // the two can never say something different for the same marker.
433
- const recheck = await checkBackfillState(context);
434
- if (recheck.status === "ok")
435
- return recheck;
436
- return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
437
- }
438
- const reason = jsonField(output, "failure_reason");
439
- if (reason === "backfill_already_running") {
440
- return fail("backfill-complete", "backfill_already_running", "backfill lock is still held and no scope-valid completion marker exists");
441
- }
442
- return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
443
- }
444
- async function checkGcState(context) {
445
- if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
446
- return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
447
- }
448
- const paths = getCollectorRuntimePaths();
449
- const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
450
- const info = await fs.stat(marker).catch(() => null);
451
- if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
452
- return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
453
- }
454
- return needsFix("gc-checked", "due", "cleanup is due");
455
- }
456
- async function fixGcState(context) {
457
- const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
458
- if (result.skipped) {
459
- return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
460
- }
461
- if (result.removed_dirs === 0) {
462
- return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
463
- }
464
- return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
465
- }
466
- /**
467
- * BLI-3619: what Cockpit is holding on this machine, in one line.
468
- *
469
- * Green means the staging tree fits under the cap. Over the cap is `needs_fix`
470
- * and the fix is the same prune the tick runs, forced past its daily throttle;
471
- * if it is still over afterwards, the message names the one command that goes
472
- * further. Every number here is metadata — byte counts and file counts, never
473
- * a path.
474
- */
475
- async function checkDiskState(context) {
476
- const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
477
- const { capBytes } = retentionOptionsFromEnv(context.io.env);
478
- const message = diskRowMessage(footprint, capBytes);
479
- return footprint.staging.total_bytes > capBytes
480
- ? needsFix("disk-bounded", "over_staging_cap", message)
481
- : ok("disk-bounded", "within_staging_cap", message);
482
- }
483
- async function fixDiskState(context) {
484
- const paths = getCollectorRuntimePaths(context.command.homeDir);
485
- const pruned = await runStagingPrune(paths, {
486
- env: context.io.env,
487
- force: true,
488
- });
489
- if (pruned.status === "fail") {
490
- return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
491
- }
492
- if (pruned.status === "skipped") {
493
- return skipped("disk-bounded", pruned.reason, "cleanup is switched off");
494
- }
495
- const footprint = await readDiskFootprint(paths);
496
- const message = diskRowMessage(footprint, pruned.cap_bytes);
497
- if (!pruned.cap_blocked_by_uncommitted) {
498
- return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
499
- }
500
- // Deliberately still `ok`: staging over the cap because evidence has not been
501
- // accepted yet is the collector working, not a machine to repair. The row
502
- // names the blockage and the one command that goes further.
503
- return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — run \`cockpit sync\` to deliver them, or \`cockpit clean --all-committed\` to drop every accepted copy now`);
504
- }
505
- function diskRowMessage(footprint, capBytes) {
506
- const staging = footprint.staging;
507
- const parts = [
508
- `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
509
- ];
510
- // BLI-3619's second half: "unknown" means the local ledger's own capped
511
- // memory cannot say, never that delivery failed — and the one command that
512
- // actually answers it is named right here, not left for a person to find.
513
- if (staging.unknown_count > 0) {
514
- parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
515
- }
516
- parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
517
- for (const vault of footprint.vaults) {
518
- parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
519
- }
520
- return parts.join("; ");
521
- }
522
- function mib(bytes) {
523
- return (bytes / (1024 * 1024)).toFixed(1);
524
- }
525
- async function checkSyncState(context) {
526
- const roots = await doctorRoots(context);
527
- if (roots.length === 0) {
528
- return needsFix("sync-fresh", "no_roots", "sync has no saved workspace roots");
529
- }
530
- // Upload state is currently device-global, so it cannot prove that every
531
- // saved root succeeded. Doctor therefore performs one explicit sync per root
532
- // and only turns green from those command receipts.
533
- return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
534
- }
535
- /**
536
- * `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
537
- * for human text; see AGENTS.md logging conventions), so this is a real parse
538
- * rather than the doctor module's usual regex field-scrape — which cannot
539
- * disambiguate same-named fields nested under `codex_sessions.codex` vs
540
- * `codex_sessions.claude` (BLI-2728).
541
- */
542
- function parseDoctorSyncJson(stdout) {
543
- try {
544
- const parsed = JSON.parse(stdout.trim());
545
- return parsed && typeof parsed === "object" ? parsed : null;
546
- }
547
- catch (error) {
548
- // `null` sends doctor back to its regex field-scrape, quietly losing the
549
- // BLI-2728 disambiguation. Something wrote to stdout that was not the one
550
- // JSON document the contract promises — a stray console.log in the
551
- // collector would do exactly this and look like nothing at all.
552
- console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
553
- reason: "sync_json_unparseable",
554
- byte_size: stdout.length,
555
- ...describeError(error),
556
- }));
557
- return null;
558
- }
559
- }
560
- /**
561
- * BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
562
- * object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
563
- * is a backlog that is draining, not a failure — `attributedSyncRunStatus`
564
- * marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
565
- * exec sees `code !== 0`) purely because objects remain queued, with the
566
- * per-repo upload itself still having succeeded. A genuine failure (auth,
567
- * server rejection, network, a real upload_failed outcome, an unposted
568
- * session report) must still read red — this only fires when NOTHING else in
569
- * the tick's own summary looks wrong. Pure so it is unit-testable without a
570
- * live exec/fs harness; the remaining-object count is read straight from the
571
- * tick's own summary, never recomputed.
572
- */
573
- export function syncBacklogDrainingVerdict(parsed) {
574
- if (!parsed)
575
- return null;
576
- const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
577
- if (deferredObjects <= 0)
578
- return null;
579
- const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
580
- const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
581
- const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
582
- ? parsed.raw_evidence_retry_reasons.length
583
- : 0;
584
- const sessions = asRecord(parsed.codex_sessions);
585
- const reportPosted = sessions?.["report_posted"];
586
- const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
587
- const claudeSessions = asRecord(sessions?.["claude"]);
588
- const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
589
- const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
590
- const onlyDeferredObjectBudget = deferredBytes === 0 &&
591
- failedCount === 0 &&
592
- retryReasons === 0 &&
593
- reportPosted === true &&
594
- codexReadFailures === 0 &&
595
- claudeReadFailures === 0 &&
596
- claudeSidecarsFailed === 0;
597
- return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
598
- }
599
- function positiveNumberOrZero(value) {
600
- return typeof value === "number" && Number.isFinite(value) && value > 0
601
- ? value
602
- : 0;
603
- }
604
- async function fixSyncState(context) {
605
- const exec = context.io.exec;
606
- if (!exec)
607
- return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
608
- const roots = await doctorRoots(context);
609
- if (roots.length === 0) {
610
- return fail("sync-fresh", "no_roots", "sync has no saved workspace roots");
611
- }
612
- // The remembered limits have to ride along, or the doctor's own verification
613
- // sync scans differently from every other run and can fail closed on a
614
- // machine the operator already fixed by hand (BLI-2362).
615
- const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
616
- for (const repoRoot of roots) {
617
- const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
618
- if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
619
- args.push("--dashboard-url", context.command.dashboardUrl);
620
- }
621
- const result = await exec("cockpit", args);
622
- const parsed = parseDoctorSyncJson(result.stdout);
623
- const output = `${result.stdout}\n${result.stderr}`;
624
- const status = jsonField(output, "status");
625
- if (result.code !== 0) {
626
- const draining = syncBacklogDrainingVerdict(parsed);
627
- if (draining) {
628
- return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
629
- `file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
630
- "rerun `cockpit sync` to continue");
631
- }
632
- return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
633
- }
634
- if (status !== "uploaded") {
635
- return fail("sync-fresh", status ?? "sync_unverified", `sync did not return an uploaded receipt for ${repoRoot}`);
636
- }
637
- }
638
- return ok("sync-fresh", "synced", roots.length === 1
639
- ? "ran `cockpit sync` and received an uploaded receipt"
640
- : `received uploaded receipts for ${roots.length} saved roots`);
641
- }
642
- async function doctorRoots(context) {
643
- if (context.command.repoRoot)
644
- return [context.command.repoRoot];
645
- return savedRoots();
646
- }
647
- async function maybeReportDoctorEvents(context, rows) {
648
- if (context.command.dryRun)
649
- return;
650
- await context.deps.reportInstallEvents({
651
- dashboardUrl: context.command.dashboardUrl,
652
- command: "doctor",
653
- events: rows.map(doctorEvent),
654
- json: context.command.json,
655
- io: context.io,
656
- });
657
- }
658
- function writeDoctorOutput(command, io, rows) {
659
- if (command.json) {
660
- writeLine(io.stdout, JSON.stringify({
661
- status: rows.some((row) => row.status === "fail" || row.hardStop)
662
- ? "blocked"
663
- : "pass",
664
- dry_run: command.dryRun,
665
- steps: rows,
666
- }, null, 2));
667
- return;
668
- }
669
- writeLine(io.stdout, command.dryRun ? "Tower doctor dry-run" : "Tower doctor");
670
- // The machine `code` stays in `--json`; a human reading the table wants the
671
- // sentence, not the label (BLI-3194).
672
- writeLine(io.stdout, "state step result");
673
- for (const row of rows) {
674
- writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
675
- }
676
- const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
677
- for (const row of explanations) {
678
- writeLine(io.stderr, "");
679
- writeLine(io.stderr, `${row.id}:`);
680
- writeLine(io.stderr, row.message);
681
- }
682
- }
683
- function doctorEvent(row) {
684
- const status = row.status === "fail" || row.hardStop
685
- ? "fail"
686
- : row.status === "skipped"
687
- ? "skipped"
688
- : "ok";
689
- if (status === "ok")
690
- return { step: row.id, status };
691
- // BLI-2542. The step already computed why it failed and already printed it to
692
- // the operator; before this, only the bucket travelled. `autostart-alive`
693
- // reports two distinct problems at once on Windows, so the detail carries the
694
- // whole message rather than a first line.
695
- const detail = redactedHealthDetail(row.message);
696
- return {
697
- step: row.id,
698
- status,
699
- error_code: sanitizeEventCode(row.code),
700
- // A detail that only repeats the bucket is noise, not a reason.
701
- ...(detail && detail !== sanitizeEventCode(row.code)
702
- ? { error_detail: detail }
703
- : {}),
704
- };
705
- }
706
- function doctorMark(row) {
707
- if (row.status === "fail" || row.hardStop)
708
- return "❌";
709
- if (row.status === "needs_fix" || row.fixed)
710
- return "🔧";
711
- return "✅";
712
- }
713
- function ok(id, code, message) {
714
- return { id, status: "ok", code, message };
715
- }
716
- function skipped(id, code, message) {
717
- return { id, status: "skipped", code, message };
718
- }
719
- function needsFix(id, code, message) {
720
- return { id, status: "needs_fix", code, message };
721
- }
722
- function fail(id, code, message) {
723
- return { id, status: "fail", code, message };
724
- }
725
- function hardStop(id, code, message) {
726
- return { id, status: "fail", code, message, hardStop: true };
727
- }
728
- function dryRunPreview(state) {
729
- const preview = { ...state };
730
- delete preview.hardStop;
731
- return {
732
- ...preview,
733
- status: "needs_fix",
734
- message: `would fix: ${oneLine(state.message)}`,
735
- };
736
- }
737
- function isInteractiveDoctorFix(context) {
738
- return !context.command.json && Boolean(context.io.stdin.isTTY);
739
- }
740
- async function savedRoots() {
741
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
742
- return normalizeCollectionRoots(config?.default_repo_paths ?? []);
743
- }
744
- function parseNpmVersion(stdout) {
745
- const trimmed = stdout.trim();
746
- if (!trimmed)
747
- return null;
748
- try {
749
- const parsed = JSON.parse(trimmed);
750
- return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
751
- }
752
- catch {
753
- // Deliberately silent (BLI-3238). `npm view ... version` prints a bare
754
- // `1.2.3` without `--json` and a quoted `"1.2.3"` with it; the parse is
755
- // the test for which one this npm produced, and the unquoted fallback is
756
- // the intended handling of the other form, not a failure.
757
- return trimmed.replace(/^"|"$/gu, "") || null;
758
- }
759
- }
760
- function reexecDoctor(command, io) {
761
- const args = ["do-everything"];
762
- if (command.repoRoot)
763
- args.push("--workspace", command.repoRoot);
764
- if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
765
- args.push("--dashboard-url", command.dashboardUrl);
766
- }
767
- if (command.updateTag)
768
- args.push("--update-tag", command.updateTag);
769
- if (command.json)
770
- args.push("--json");
771
- const exec = io.interactiveExec ?? createInteractiveExecRunner();
772
- return exec("cockpit", args, {
773
- env: {
774
- ...process.env,
775
- ...io.env,
776
- COCKPIT_DOCTOR_REEXEC: "1",
777
- },
778
- }).then((result) => result.code);
779
- }
780
- function capturedIo(io, forward) {
781
- const stdoutChunks = [];
782
- const stderrChunks = [];
783
- return {
784
- io: {
785
- ...io,
786
- stdout: captureStream(io.stdout, stdoutChunks, forward),
787
- stderr: captureStream(io.stderr, stderrChunks, forward),
788
- },
789
- stdout: () => stdoutChunks.join(""),
790
- stderr: () => stderrChunks.join(""),
791
- };
792
- }
793
- function captureStream(target, chunks, forward) {
794
- return {
795
- write(chunk, encoding, callback) {
796
- const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
797
- chunks.push(text);
798
- if (forward) {
799
- if (typeof encoding === "function") {
800
- target.write(chunk, encoding);
801
- }
802
- else {
803
- target.write(chunk, encoding, callback);
804
- }
805
- }
806
- else if (typeof encoding === "function") {
807
- encoding();
808
- }
809
- else {
810
- callback?.();
811
- }
812
- return true;
813
- },
814
- };
815
- }
816
- function jsonField(output, field) {
817
- const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
818
- const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
819
- return match?.[1] ?? null;
820
- }
821
- function selfUpdateFailureCode(error) {
822
- const record = asRecord(error);
823
- if (record && record["eacces"] === true)
824
- return "eacces_needs_chown";
825
- return "npm_install_failed";
826
- }
827
- function selfUpdateFailureMessage(error) {
828
- const record = asRecord(error);
829
- const stderr = asRecord(record?.["result"])?.["stderr"] &&
830
- typeof asRecord(record?.["result"])?.["stderr"] === "string"
831
- ? String(asRecord(record?.["result"])?.["stderr"])
832
- : "";
833
- if (record?.["eacces"] === true) {
834
- const prefix = npmPrefixFromError(stderr);
835
- return [
836
- "npm global install hit a permissions problem.",
837
- "What you can do:",
838
- ` 1) Fix npm ownership once: sudo chown -R $(whoami) ${prefix}/lib/node_modules/@bli-cockpit ${prefix}/bin/cockpit`,
839
- " 2) No sudo? Send this output to Edward.",
840
- " 3) Do not use `sudo npm i -g`; it makes the ownership problem come back.",
841
- ].join("\n");
842
- }
843
- return "npm install failed; Tower CLI was not refreshed.";
844
- }
845
- function npmPrefixFromError(stderr) {
846
- if (stderr.includes("/usr/local/"))
847
- return "/usr/local";
848
- if (stderr.includes("/opt/homebrew/"))
849
- return "/opt/homebrew";
850
- const nvm = stderr.match(/(\/Users\/[^/\s]+\/\.nvm\/versions\/node\/[^/\s]+)/u);
851
- return nvm?.[1] ?? "/opt/homebrew";
852
- }
853
- function onboardOneLiner(command) {
854
- const workspace = command.repoRoot ?? "$PWD";
855
- const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
856
- ? ""
857
- : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
858
- return `cockpit onboard --workspace ${shellQuote(workspace)}${dashboard}`;
859
- }
860
- function shellQuote(value) {
861
- if (value === "$PWD")
862
- return '"$PWD"';
863
- return `'${value.replace(/'/gu, "'\\''")}'`;
864
- }
865
- function sanitizeEventCode(value) {
866
- return (value
867
- .trim()
868
- .toLowerCase()
869
- .replace(/[^a-z0-9_]+/gu, "_")
870
- .replace(/^_+|_+$/gu, "")
871
- .slice(0, 120) || "unknown");
872
- }
873
- function oneLine(value) {
874
- return value.split("\n")[0] ?? value;
875
- }
876
- function asRecord(value) {
877
- return value && typeof value === "object" ? value : null;
878
- }
879
- function writeLine(stream, text) {
880
- stream.write(`${text}\n`);
881
- }
134
+ // Re-exported so every consumer keeps importing from `./doctor.js` regardless
135
+ // of which sibling a symbol now lives in.
136
+ export { backfillCompletionStepState, syncBacklogDrainingVerdict };