@bli-cockpit/cli 0.2.23 → 0.2.25

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.
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { autostartStatus, installAutostartAgent, } from "./autostart.js";
4
+ export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
5
+ const AUTOSTART_REPAIR_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const DETAIL_MAX_CHARS = 300;
7
+ export async function runAutostartSelfHeal(paths, options) {
8
+ const platform = options.platform ?? process.platform;
9
+ if (platform !== "win32")
10
+ return null;
11
+ if (options.repoRoots.length === 0)
12
+ return null;
13
+ const status = await autostartStatus({
14
+ homeDir: options.homeDir,
15
+ repoRoot: options.repoRoots[0],
16
+ repoRoots: options.repoRoots,
17
+ dashboardUrl: options.dashboardUrl,
18
+ exec: options.exec,
19
+ platform,
20
+ });
21
+ // Healthy is the steady state and stays silent; absent means the operator
22
+ // (or onboarding) owns the decision, not this tick.
23
+ if (status.status !== "not_loaded")
24
+ return null;
25
+ // Attempts are throttled like the self-update's (marker mtime, written for
26
+ // the attempt not the outcome) so a persistently failing repair cannot spawn
27
+ // a registration every 15 minutes. The status probe above still runs every
28
+ // tick — it is one schtasks query.
29
+ const now = options.now ?? new Date();
30
+ const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
31
+ const lastAttempt = await fs.stat(marker).catch(() => null);
32
+ if (lastAttempt &&
33
+ now.getTime() - lastAttempt.mtimeMs < AUTOSTART_REPAIR_MIN_INTERVAL_MS) {
34
+ return { status: "skipped", reason: "repair_throttled_recent_attempt" };
35
+ }
36
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
37
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
38
+ const problem = (status.message ?? "task not loaded").slice(0, DETAIL_MAX_CHARS);
39
+ const repaired = await installAutostartAgent({
40
+ homeDir: options.homeDir,
41
+ repoRoot: options.repoRoots[0],
42
+ repoRoots: options.repoRoots,
43
+ dashboardUrl: options.dashboardUrl,
44
+ exec: options.exec,
45
+ platform,
46
+ });
47
+ if (repaired.loaded) {
48
+ return { status: "ok", reason: "autostart_repaired", detail: problem };
49
+ }
50
+ return {
51
+ status: "fail",
52
+ reason: "autostart_repair_failed",
53
+ detail: (repaired.message ?? problem).slice(0, DETAIL_MAX_CHARS),
54
+ };
55
+ }
@@ -22,6 +22,7 @@ import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-s
22
22
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
23
23
  import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
24
24
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
25
+ import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
25
26
  import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
26
27
  import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
27
28
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -2172,6 +2173,7 @@ async function runSync(command, io) {
2172
2173
  // BLI-2601: self-update runs only after collection's own outcome above is
2173
2174
  // already decided and reported, win or lose. See the function doc.
2174
2175
  await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2176
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
2175
2177
  return result.exitCode;
2176
2178
  }
2177
2179
  catch (error) {
@@ -2191,9 +2193,59 @@ async function runSync(command, io) {
2191
2193
  io,
2192
2194
  });
2193
2195
  await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2196
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
2194
2197
  throw error;
2195
2198
  }
2196
2199
  }
2200
+ /**
2201
+ * BLI-2721: after the tick's collection and self-update are done and
2202
+ * reported, repair a broken/legacy autostart registration in place (Windows
2203
+ * only — see autostart-self-heal.ts for why macOS is excluded). Every error
2204
+ * path is swallowed like the self-update's: heal outcomes are their own
2205
+ * receipts, never a sync failure.
2206
+ */
2207
+ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
2208
+ let result;
2209
+ try {
2210
+ const rawExec = io.exec;
2211
+ if (!rawExec)
2212
+ return;
2213
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
2214
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
2215
+ result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
2216
+ homeDir: command.homeDir,
2217
+ repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
2218
+ dashboardUrl: command.dashboardUrl,
2219
+ exec,
2220
+ });
2221
+ }
2222
+ catch (error) {
2223
+ result = {
2224
+ status: "fail",
2225
+ reason: "autostart_self_heal_threw",
2226
+ detail: redactedSyncErrorDetail(error),
2227
+ };
2228
+ }
2229
+ // Steady state (healthy, absent, non-Windows, no roots) and the daily
2230
+ // throttle are silent; an actual repair attempt reports either way.
2231
+ if (!result || result.reason === "repair_throttled_recent_attempt")
2232
+ return;
2233
+ await reportInstallEventsBestEffort({
2234
+ homeDir: command.homeDir,
2235
+ dashboardUrl,
2236
+ command: "sync",
2237
+ events: [
2238
+ {
2239
+ step: "autostart_repair",
2240
+ status: result.status,
2241
+ ...(result.status === "ok" ? {} : { error_code: result.reason }),
2242
+ ...(result.detail ? { error_detail: result.detail } : {}),
2243
+ },
2244
+ ],
2245
+ json: command.json,
2246
+ io,
2247
+ });
2248
+ }
2197
2249
  /**
2198
2250
  * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
2199
2251
  * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
@@ -2478,6 +2530,31 @@ async function runSyncLocked(command, io) {
2478
2530
  writeLine(io.stdout, rawEvidenceGcSummary(gc));
2479
2531
  return syncResult(run);
2480
2532
  }
2533
+ // Zero worktrees is a legitimate steady state, not a failure: an approved
2534
+ // root can hold no git repos, and sessions upload independently of
2535
+ // worktrees (session-first, BLI-2581). This used to throw "Sync produced no
2536
+ // result", which painted ~90 false-red sync_failed receipts per day on one
2537
+ // fleet machine with a single empty root and taught people to ignore
2538
+ // sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
2539
+ if (run.outcomes.length === 0) {
2540
+ const runStatus = attributedSyncRunStatus(run);
2541
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
2542
+ if (command.json) {
2543
+ writeLine(io.stdout, JSON.stringify({
2544
+ mode: "no_worktrees",
2545
+ status: runStatus,
2546
+ collection_complete: run.ok,
2547
+ codex_sessions: run.summary,
2548
+ raw_evidence_gc: gc,
2549
+ }, null, 2));
2550
+ return syncResult(run);
2551
+ }
2552
+ writeLine(run.ok ? io.stdout : io.stderr, `Cockpit sync ${runStatus}: no git worktrees under this root; session scan ran.`);
2553
+ writeAgentSessionSummary(io, run.summary);
2554
+ if (gc && !gc.skipped)
2555
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
2556
+ return syncResult(run);
2557
+ }
2481
2558
  const result = run.outcomes[0]?.sync;
2482
2559
  if (!result) {
2483
2560
  throw new Error("Sync produced no result for the repo worktree.");
@@ -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.23");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.25");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {