@bli-cockpit/cli 0.2.47 → 0.2.49

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 (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. package/package.json +2 -2
@@ -1,59 +1,18 @@
1
- /**
2
- * The `cockpit` subcommands and the router that picks one.
3
- *
4
- * Read `runLocalCockpitCli` below as the table of contents: one case per
5
- * command, each delegating to a `run<Command>` function. The commands that
6
- * grew their own supporting cast now live beside this file and are re-exported
7
- * here so `./local.js` stays the single published entry point (BLI-3104):
8
- *
9
- * local-help.ts what --help prints, and the recognised command names
10
- * cli-io.ts stdout/stderr/stdin plumbing and the production CliIo
11
- * install-receipts.ts the named step results this machine reports back
12
- * heartbeat.ts the per-tick "I am alive, and these are my roots"
13
- * local-auth.ts which email, the OTP exchange, pairing with fallback
14
- * collection-roots.ts which folders may be collected, saved and read back
15
- * local-discovery.ts finding git worktrees inside those roots
16
- * collection-report.ts how one collection run reads back to a person
17
- * install-update.ts install, update, self-update, release
18
- * status.ts `cockpit status`
19
- * sessions.ts `cockpit sessions`
20
- * jarvis.ts terminal adapter for the shared JARVIS gateway
21
- * scout.ts `cockpit scout` — the board, and the three card verbs
22
- * (+ scout-render.ts, the pure layout half)
23
- * ops.ts `cockpit ops` — the pipeline board and one recompile
24
- * (+ ops-render.ts, the pure layout half, shared with
25
- * `cockpit brief status`)
26
- * slack.ts `cockpit slack` — bot coverage, and the messages
27
- * workbook.ts `cockpit workbook` — the project document library
28
- * (+ workbook-render.ts, the pure layout half)
29
- * brief.ts `cockpit brief` — the TODAY page in the terminal
30
- * brief-edit.ts `cockpit brief edit` — rewrite it like a document
31
- * brief-rewrite.ts `cockpit brief rewrite` — have Tower compile it again
32
- * editor.ts the one place a person's own $EDITOR is spawned
33
- * correct.ts `cockpit correct` — one line on it is wrong
34
- * notes.ts `cockpit notes` — the meeting-notes surface, typed
35
- * notes-file.ts the local screen a note file passes before it is sent
36
- * tower-command.ts open a Tower session, call a route, name the refusal
37
- * settings.ts `cockpit settings` and `cockpit model`
38
- * settings-render.ts how a settings answer reads in a terminal
39
- * team.ts `cockpit team` — members, invite, role
40
- * autostart-heal.ts the internal detached one-shot the macOS tick spawns
41
- * to re-register launchd after the tick itself exits
42
- *
43
- * What stays here is orchestration: onboard, login/logout/start, the sync tick,
44
- * analyze, serve, autostart and agent-rules.
45
- */
46
- import path from "node:path";
47
- import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
1
+ import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
48
2
  import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
49
- import { describeError, isMissingFileFailure } from "../health-detail.js";
50
- import { sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
51
- import { addInstallEvent, classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
52
- import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
53
- import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
54
- import { discoverCommandWorktrees, rememberDiscoveryLimits } from "./local-discovery.js";
55
- import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidenceSyncLine, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
56
- import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } from "./install-update.js";
3
+ import { describeError } from "../health-detail.js";
4
+ import { reportInstallEventsBestEffort } from "./install-receipts.js";
5
+ import { persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
6
+ import { rememberDiscoveryLimits } from "./local-discovery.js";
7
+ import { runInstall, runRelease, runSelfUpdate, runUpdate } from "./install-update.js";
8
+ import { runOnboard } from "./onboard.js";
9
+ import { runLogin } from "./login.js";
10
+ import { runLogout } from "./logout.js";
11
+ import { runStart } from "./start.js";
12
+ import { runSync } from "./sync.js";
13
+ import { runAnalyze } from "./analyze.js";
14
+ import { runBackfillCommand } from "./backfill.js";
15
+ import { runDoctor } from "./doctor.js";
57
16
  import { runStatus } from "./status.js";
58
17
  import { runSessions } from "./sessions.js";
59
18
  import { runJarvis } from "./jarvis.js";
@@ -62,30 +21,16 @@ import { runOps } from "./ops.js";
62
21
  import { runSlack } from "./slack.js";
63
22
  import { runModel, runSettings } from "./settings.js";
64
23
  import { runTeam } from "./team.js";
65
- import { asRecord, callTower, openTower } from "./tower-command.js";
66
24
  import { runWorkbook } from "./workbook.js";
67
25
  import { runBrief } from "./brief.js";
68
26
  import { runBriefEdit } from "./brief-edit.js";
69
27
  import { runBriefRewrite } from "./brief-rewrite.js";
70
28
  import { runCorrect } from "./correct.js";
71
29
  import { runNotes } from "./notes.js";
72
- import { createCollectorServer } from "../server.js";
73
- import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
74
- import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
75
- import { runDoctor } from "./doctor.js";
76
- import { inspectBackfillLock } from "../backfill-lock.js";
77
- import { parseLocalArgs, normalizeUrl } from "./local-args.js";
78
- import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
79
- import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, logoutLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, startLocalWorkContext, } from "../local-state.js";
80
- import { acquireSyncLock } from "../sync-lock.js";
81
- import { runAttributedWorktreeSync, } from "./session-sync.js";
82
- import { COLLECTION_ROOT_REQUIRED, CollectionRootRequiredError, missingCollectionRootMessage, } from "../onboarding-roots.js";
83
- import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
84
- import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
85
- import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
86
- import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
87
- import { runAutostartHealDetached } from "./autostart-heal.js";
88
- import { normalizeCollectionRoots } from "../root-normalization.js";
30
+ import { runServe } from "./serve.js";
31
+ import { runAutostart } from "./autostart-command.js";
32
+ import { runAgentRules } from "./agent-rules-command.js";
33
+ import { parseLocalArgs } from "./local-args.js";
89
34
  // `./local.js` is the published entry point for this command surface: the
90
35
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
91
36
  // all import from here. Splitting the file must not move a name off it.
@@ -200,268 +145,6 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
200
145
  return 1;
201
146
  }
202
147
  }
203
- function addOnboardFailureEvent(events, blocker) {
204
- const step = onboardFailureStep(blocker, events);
205
- const existing = events.find((event) => event.step === step && event.status === "fail");
206
- if (existing)
207
- return;
208
- addInstallEvent(events, step, "fail", blocker);
209
- }
210
- function addAutostartInstallEvent(events, result) {
211
- if (!result) {
212
- addInstallEvent(events, "autostart", "skipped", "runner_unavailable");
213
- return;
214
- }
215
- if (result.status === "unsupported") {
216
- addInstallEvent(events, "autostart", "skipped", "unsupported", result.message);
217
- return;
218
- }
219
- addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined, result.loaded === false ? result.message : undefined);
220
- }
221
- function onboardAutostartFailed(result) {
222
- return (result !== null &&
223
- result.status !== "unsupported" &&
224
- result.loaded === false);
225
- }
226
- function writeOnboardAutostartBlocker(io, result) {
227
- writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
228
- if (result?.message)
229
- writeLine(io.stderr, `Failure: ${result.message}`);
230
- writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
231
- }
232
- function onboardFailureStep(blocker, events) {
233
- if (blocker === COLLECTION_ROOT_REQUIRED)
234
- return "install";
235
- if (blocker === "device_pairing")
236
- return "pair";
237
- if (blocker === "work_context" || blocker === "ticket_binding" || blocker === "ticket") {
238
- return "work_context";
239
- }
240
- if (blocker === "network_or_ingest")
241
- return "sync";
242
- if (blocker === "install")
243
- return "install";
244
- const lastIncomplete = ["install", "auth", "pair", "work_context", "sync"].find((step) => !events.some((event) => event.step === step));
245
- return lastIncomplete ?? "sync";
246
- }
247
- /** Refreshes the host autostart backend after successful onboarding. */
248
- async function refreshOnboardAutostart(command, roots, io) {
249
- if (!io.exec)
250
- return null;
251
- const result = await installAutostartAgent({
252
- homeDir: command.homeDir,
253
- repoRoot: roots[0],
254
- repoRoots: roots,
255
- dashboardUrl: command.dashboardUrl,
256
- exec: io.exec,
257
- });
258
- if (command.json)
259
- return result;
260
- if (result.status === "unsupported") {
261
- writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
262
- return result;
263
- }
264
- writeLine(io.stdout, result.loaded
265
- ? "Background autostart refreshed; Tower syncs at login and every 15 min."
266
- : "Background autostart refreshed, but the operating-system scheduler reported a problem; check `cockpit autostart status`.");
267
- writeLine(io.stdout, autostartLocationLine(result));
268
- return result;
269
- }
270
- async function refreshOnboardAgentRules(command, roots, io) {
271
- const result = await installAgentRules({
272
- homeDir: command.homeDir,
273
- scopePaths: roots,
274
- });
275
- if (command.json)
276
- return result;
277
- writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
278
- for (const target of result.targets) {
279
- writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
280
- }
281
- return result;
282
- }
283
- function onboardAgentRulesInstallLine(result) {
284
- if (result.targets.some((target) => target.stale_block_replaced)) {
285
- return "updated; replaced stale Tower ticket-binding guidance.";
286
- }
287
- switch (result.status) {
288
- case "created":
289
- return "installed.";
290
- case "updated":
291
- return "updated.";
292
- case "unchanged":
293
- return "already current.";
294
- case "missing":
295
- return "not installed.";
296
- }
297
- }
298
- function writeOnboardLiveStatus(io, command, roots, options) {
299
- writeLine(io.stdout, "You're live.");
300
- writeLine(io.stdout, "Collecting from:");
301
- for (const root of roots)
302
- writeLine(io.stdout, `- ${root}`);
303
- writeLine(io.stdout, `Paired user: ${options.pair?.session.email ??
304
- options.pair?.session.auth_subject_id ??
305
- (options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
306
- writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
307
- writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
308
- writeLine(io.stdout, `Historical backfill: complete (${options.backfill.counts.backfilled} uploaded, ${options.backfill.counts.skipped} explicit skip(s))`);
309
- writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
310
- writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
311
- writeLine(io.stdout, "Next: cockpit status");
312
- }
313
- function requiredOnboardBackfillCommand(command) {
314
- return {
315
- homeDir: command.homeDir,
316
- all: true,
317
- dryRun: false,
318
- maxDepth: command.maxDepth,
319
- maxRepos: command.maxRepos,
320
- yes: true,
321
- json: true,
322
- };
323
- }
324
- async function runRequiredOnboardBackfill(command, io) {
325
- const backfillCommand = requiredOnboardBackfillCommand(command);
326
- const retryCommand = backfillRetryCommand(backfillCommand);
327
- try {
328
- const result = await runBackfill(backfillCommand, io);
329
- return {
330
- status: result.status,
331
- failureReason: result.status === "complete"
332
- ? null
333
- : result.failure_reason ?? `backfill_${result.status}`,
334
- retryCommand: result.retry_command,
335
- result,
336
- };
337
- }
338
- catch (error) {
339
- return {
340
- status: "failed",
341
- failureReason: errorMessage(error),
342
- retryCommand,
343
- result: null,
344
- };
345
- }
346
- }
347
- function onboardBackfillPayload(outcome) {
348
- return (outcome.result ?? {
349
- status: "blocked",
350
- failure_reason: outcome.failureReason,
351
- retry_command: outcome.retryCommand,
352
- });
353
- }
354
- /**
355
- * The blocked-backfill result, identical in both onboarding arms. `extra`
356
- * carries the arm-specific keys and is spread where those keys appeared before,
357
- * so the `--json` key order is unchanged.
358
- */
359
- function writeOnboardBackfillBlockedResult(io, options) {
360
- if (!options.json) {
361
- writeOnboardBackfillBlocker(io, options.backfill);
362
- return;
363
- }
364
- writeLine(io.stdout, JSON.stringify({
365
- ...options.base,
366
- ...options.extra,
367
- backfill: onboardBackfillPayload(options.backfill),
368
- blocker: "backfill_incomplete",
369
- backfill_failure_reason: options.backfill.failureReason,
370
- next_step: options.backfill.retryCommand,
371
- }, null, 2));
372
- }
373
- function writeOnboardBackfillBlocker(io, outcome) {
374
- writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
375
- writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
376
- writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
377
- }
378
- function backgroundSyncLine(result) {
379
- if (!result)
380
- return "not refreshed (autostart runner unavailable)";
381
- if (result.status === "unsupported")
382
- return `unsupported (${result.message})`;
383
- if (result.status === "installed" && result.loaded)
384
- return "installed";
385
- if (result.status === "installed") {
386
- return `installed with warning (${result.message ?? "operating-system scheduler load failed"})`;
387
- }
388
- return result.status;
389
- }
390
- function writeOnboardBanner(command, io) {
391
- writeLine(io.stdout, "Setting up Tower");
392
- writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
393
- writeLine(io.stdout, `Ticket: ${displayTicketId(command.activeTicketId)}`);
394
- }
395
- /**
396
- * Step 2 of onboarding: reuse the device session when it already belongs to
397
- * this owner on this dashboard, otherwise sign in and pair — logging the old
398
- * session out first when it belongs to someone else.
399
- *
400
- * Returns null when an existing session was reused, which is why the caller's
401
- * `pair` stays null and the readiness proof falls back to the status file.
402
- */
403
- async function pairForOnboarding(command, input, installEvents, io) {
404
- const installedStatus = await inspectLocalCollectorStatus({
405
- homeDir: command.homeDir,
406
- repoRoot: input.primaryRoot,
407
- branch: command.branch,
408
- });
409
- const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
410
- const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
411
- if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
412
- addInstallEvent(installEvents, "auth", "skipped", "existing_session");
413
- addInstallEvent(installEvents, "pair", "skipped", "existing_session");
414
- if (!command.json) {
415
- writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
416
- }
417
- return null;
418
- }
419
- if (installedStatus.session_state === "valid") {
420
- await logoutLocalCollector({ homeDir: command.homeDir });
421
- if (!command.json) {
422
- writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
423
- }
424
- }
425
- const authResult = await requestPairingAccessTokenDetailed({
426
- dashboardUrl: command.dashboardUrl,
427
- email: input.claimedOwnerEmail,
428
- noAuth: command.noAuth,
429
- json: command.json,
430
- }, io);
431
- addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
432
- const pair = await pairLocalCollectorWithAuthFallback({
433
- homeDir: command.homeDir,
434
- dashboardUrl: command.dashboardUrl,
435
- claimedOwnerEmail: input.claimedOwnerEmail,
436
- deviceName: command.deviceName,
437
- pollIntervalMs: command.pollIntervalMs,
438
- timeoutMs: command.timeoutMs,
439
- pairingAccessToken: authResult.accessToken,
440
- fetch: io.fetch,
441
- onPairStarted: command.json
442
- ? undefined
443
- : (request) => writePairingInstructions(io, request),
444
- }, io);
445
- addInstallEvent(installEvents, "pair", "ok");
446
- if (!command.json) {
447
- writeLine(io.stdout, "2/5 Device paired.");
448
- writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
449
- writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
450
- }
451
- return pair;
452
- }
453
- /**
454
- * The last two setup steps of a successful onboard, in the order both arms run
455
- * them. `ok` is false only when a supported scheduler was asked to load
456
- * recurring collection and refused — an unsupported host is not a failure.
457
- */
458
- async function completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io) {
459
- const agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
460
- addInstallEvent(installEvents, "agent_rules", "ok");
461
- const autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
462
- addAutostartInstallEvent(installEvents, autostart);
463
- return { agentRules, autostart, ok: !onboardAutostartFailed(autostart) };
464
- }
465
148
  async function runDoctorLogin(command, io) {
466
149
  return runLogin({
467
150
  kind: "login",
@@ -487,1455 +170,4 @@ async function resolveAndSaveDoctorRoots(command, io) {
487
170
  if (!command.json) {
488
171
  writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
489
172
  }
490
- }
491
- /**
492
- * Every onboard exit funnels through here so the best-effort install-event
493
- * report always fires, pass or blocked — replaces a `finish` closure that used
494
- * to live inside `runOnboard` so the flow functions below can call it too.
495
- */
496
- async function finishOnboardRun(command, installEvents, io, code) {
497
- await reportInstallEventsBestEffort({
498
- homeDir: command.homeDir,
499
- dashboardUrl: command.dashboardUrl,
500
- command: "onboard",
501
- events: installEvents,
502
- json: command.json,
503
- io,
504
- });
505
- return code;
506
- }
507
- /**
508
- * Every onboard `--json` payload is `onboardResult` plus the roots this pass
509
- * resolved plus whatever extra keys that arm of onboarding adds. Assembling it
510
- * here means the call sites below cannot drift on key order or forget a field
511
- * a sibling call site remembers.
512
- */
513
- function onboardJsonPayload(resultStatus, command, install, pair, sync, status, rootsResult, extra = {}) {
514
- return {
515
- ...onboardResult(resultStatus, command, install, pair, sync, status),
516
- collection_roots: rootsResult?.roots ?? [],
517
- root_resolution: rootsResult,
518
- ...extra,
519
- };
520
- }
521
- function writeOnboardJson(io, payload) {
522
- writeLine(io.stdout, JSON.stringify(payload, null, 2));
523
- }
524
- /** Step 0: which folders are approved, and who claims to own this machine's uploads. */
525
- async function prepareOnboardRoots(command, installEvents, io) {
526
- const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
527
- const rootsResult = resolvedRoots.rootsResult;
528
- const collectionRoots = rootsResult.roots;
529
- const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
530
- const primaryRoot = resolvedRoots.primaryRoot;
531
- const resolvedCommand = {
532
- ...command,
533
- repoRoot: primaryRoot,
534
- collectionRoots,
535
- };
536
- if (!command.json) {
537
- writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
538
- }
539
- if (rootsResult.homeRootOptIn) {
540
- addInstallEvent(installEvents, "home_root_optin", "ok");
541
- }
542
- return {
543
- resolvedRoots,
544
- rootsResult,
545
- collectionRoots,
546
- attributionCollectionRoots,
547
- primaryRoot,
548
- existingConfig: resolvedRoots.existingConfig,
549
- resolvedCommand,
550
- };
551
- }
552
- /** Step 1: write the local config that makes this machine a known collector. */
553
- async function installOnboardConfig(command, resolvedRoots, installEvents, io) {
554
- const install = await persistOnboardingRootConfig(command, resolvedRoots);
555
- addInstallEvent(installEvents, "install", "ok");
556
- if (!command.json) {
557
- writeLine(io.stdout, "1/5 Installed local collector.");
558
- writeLine(io.stdout, `Config: ${install.paths.config_file}`);
559
- }
560
- return install;
561
- }
562
- /**
563
- * The onboarding runbook: resolve roots, install, pair, discover worktrees,
564
- * then hand off to whichever arm matches what was found — one repo runs the
565
- * single-repo sync-then-backfill sequence (`runOnboardSingleRepoFlow`),
566
- * several run the parent-folder one (`runOnboardMultiRepoFlow`). Any failure
567
- * anywhere in either arm unwinds to the single catch below.
568
- */
569
- async function runOnboard(command, io) {
570
- const installEvents = [];
571
- let install = null;
572
- let pair = null;
573
- let rootsResult = null;
574
- // `sync` has to survive a throw from inside either flow function, so it is
575
- // mutated through this holder rather than returned — a return only happens
576
- // on the success path, and the failure path needs the value too.
577
- const syncHolder = {
578
- current: null,
579
- };
580
- try {
581
- if (!command.json)
582
- writeOnboardBanner(command, io);
583
- const roots = await prepareOnboardRoots(command, installEvents, io);
584
- rootsResult = roots.rootsResult;
585
- const claimedOwnerEmail = await resolveOnboardEmail(roots.resolvedCommand, roots.collectionRoots, roots.existingConfig, io);
586
- install = await installOnboardConfig(command, roots.resolvedRoots, installEvents, io);
587
- pair = await pairForOnboarding(command, { primaryRoot: roots.primaryRoot, claimedOwnerEmail }, installEvents, io);
588
- const worktrees = await discoverCommandWorktrees(roots.collectionRoots, {
589
- maxDepth: command.maxDepth,
590
- maxRepos: command.maxRepos,
591
- homeDir: command.homeDir,
592
- allowEmpty: true,
593
- }, io);
594
- const ctx = {
595
- command,
596
- resolvedCommand: roots.resolvedCommand,
597
- collectionRoots: roots.collectionRoots,
598
- rootsResult: roots.rootsResult,
599
- install,
600
- pair,
601
- installEvents,
602
- io,
603
- worktrees,
604
- };
605
- if (worktrees.length > 1) {
606
- return await runOnboardMultiRepoFlow(ctx);
607
- }
608
- return await runOnboardSingleRepoFlow(ctx, {
609
- attributionCollectionRoots: roots.attributionCollectionRoots,
610
- primaryRoot: roots.primaryRoot,
611
- syncHolder,
612
- });
613
- }
614
- catch (error) {
615
- return await handleOnboardFailure(error, command, rootsResult, install, pair, syncHolder.current, installEvents, io);
616
- }
617
- }
618
- /** The parent-folder arm: sync every discovered worktree, then backfill and finish setup once. */
619
- async function runOnboardMultiRepoFlow(ctx) {
620
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
621
- const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
622
- addInstallEvent(installEvents, "work_context", "ok");
623
- addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
624
- if (!multi.ok) {
625
- if (command.json) {
626
- writeOnboardJson(io, onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult, {
627
- mode: "multi_repo",
628
- repos: multi.results,
629
- codex_sessions: multi.codex_sessions,
630
- blocker: "sync_blocked",
631
- next_step: "Run `cockpit sync --json` until every root returns an uploaded receipt.",
632
- }));
633
- }
634
- return finishOnboardRun(command, installEvents, io, 1);
635
- }
636
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
637
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
638
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
639
- ? undefined
640
- : backfill.failureReason ?? "backfill_incomplete");
641
- if (!completedBackfill) {
642
- writeOnboardBackfillBlockedResult(io, {
643
- json: command.json,
644
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult),
645
- extra: {
646
- mode: "multi_repo",
647
- repos: multi.results,
648
- codex_sessions: multi.codex_sessions,
649
- },
650
- backfill,
651
- });
652
- return finishOnboardRun(command, installEvents, io, 1);
653
- }
654
- const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
655
- const { agentRules, autostart } = multiSetup;
656
- const onboardOk = multiSetup.ok;
657
- if (command.json) {
658
- writeOnboardJson(io, onboardJsonPayload(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null, rootsResult, {
659
- agent_rules: agentRules,
660
- autostart,
661
- backfill: completedBackfill,
662
- mode: "multi_repo",
663
- repos: multi.results,
664
- codex_sessions: multi.codex_sessions,
665
- ...(!onboardOk && multi.ok
666
- ? {
667
- blocker: "autostart_load_failed",
668
- next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
669
- }
670
- : {}),
671
- }));
672
- }
673
- if (onboardOk && !command.json) {
674
- writeLine(io.stdout, "PASS: Tower is set up and collecting.");
675
- writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
676
- pair,
677
- status: null,
678
- sync: null,
679
- agentRules,
680
- autostart,
681
- initialSyncOk: true,
682
- backfill: completedBackfill,
683
- });
684
- }
685
- else if (multi.ok && !command.json) {
686
- writeOnboardAutostartBlocker(io, autostart);
687
- }
688
- return finishOnboardRun(command, installEvents, io, onboardOk ? 0 : 1);
689
- }
690
- /** The single-repo arm: start the work context, sync it, backfill, then finish setup once. */
691
- async function runOnboardSingleRepoFlow(ctx, single) {
692
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
693
- const worktreeRoot = worktrees[0]?.repo_root ?? single.primaryRoot;
694
- const context = await startLocalWorkContext({
695
- homeDir: command.homeDir,
696
- repoRoot: worktreeRoot,
697
- branch: command.branch,
698
- activeTicketId: command.activeTicketId,
699
- });
700
- if (!command.json) {
701
- writeLine(io.stdout, "3/5 Work context active.");
702
- writeLine(io.stdout, `Repo: ${context.repo}`);
703
- writeLine(io.stdout, `Branch: ${context.branch}`);
704
- writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
705
- writeLine(io.stdout, `Context: ${context.work_context_id}`);
706
- }
707
- addInstallEvent(installEvents, "work_context", "ok");
708
- const run = await runAttributedWorktreeSync({
709
- homeDir: command.homeDir,
710
- dashboardUrl: command.dashboardUrl,
711
- collectionRoots: single.attributionCollectionRoots,
712
- startContexts: false,
713
- worktrees,
714
- fetchImpl: io.fetch,
715
- });
716
- const sync = run.outcomes[0]?.sync ?? null;
717
- single.syncHolder.current = sync;
718
- if (!sync) {
719
- throw new Error("Onboard sync produced no result for the repo worktree.");
720
- }
721
- const status = await inspectLocalCollectorStatus({
722
- homeDir: command.homeDir,
723
- repoRoot: worktreeRoot,
724
- branch: command.branch,
725
- });
726
- if (!run.ok || sync.status !== "uploaded") {
727
- addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
728
- const collectionRunStatus = attributedSyncRunStatus(run);
729
- if (command.json) {
730
- writeOnboardJson(io, onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult, {
731
- codex_sessions: run.summary,
732
- collection_status: collectionRunStatus,
733
- }));
734
- }
735
- else {
736
- const syncFailureReason = sync.status === "spooled" ? sync.failure_reason : null;
737
- const retryCommand = sync.status === "spooled"
738
- ? sync.retry_command
739
- : `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
740
- writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
741
- writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? collectionRunStatus : run.summary.report_reason)}`);
742
- writeAgentSessionSummary(io, run.summary);
743
- writeLine(io.stderr, `Retry: ${retryCommand}`);
744
- }
745
- return finishOnboardRun(command, installEvents, io, 1);
746
- }
747
- addInstallEvent(installEvents, "sync", "ok");
748
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
749
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
750
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
751
- ? undefined
752
- : backfill.failureReason ?? "backfill_incomplete");
753
- if (!completedBackfill) {
754
- writeOnboardBackfillBlockedResult(io, {
755
- json: command.json,
756
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult),
757
- extra: { codex_sessions: run.summary },
758
- backfill,
759
- });
760
- return finishOnboardRun(command, installEvents, io, 1);
761
- }
762
- if (command.json) {
763
- const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
764
- const { agentRules, autostart } = jsonSetup;
765
- const onboardOk = jsonSetup.ok;
766
- writeOnboardJson(io, onboardJsonPayload(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status, rootsResult, {
767
- agent_rules: agentRules,
768
- autostart,
769
- backfill: completedBackfill,
770
- codex_sessions: run.summary,
771
- ...(!onboardOk
772
- ? {
773
- blocker: "autostart_load_failed",
774
- next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
775
- }
776
- : {}),
777
- }));
778
- return finishOnboardRun(command, installEvents, io, onboardOk ? 0 : 1);
779
- }
780
- writeLine(io.stdout, "4/5 Uploaded what you worked on.");
781
- writeLine(io.stdout, `HTTP: ${sync.http_status}`);
782
- writeLine(io.stdout, `Things recorded: ${sync.event_count}`);
783
- writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
784
- writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
785
- writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
786
- writeLine(io.stdout, rawEvidenceSyncLine(sync));
787
- writeAgentSessionSummary(io, run.summary);
788
- writeLine(io.stdout, "5/5 Status ready.");
789
- writeLine(io.stdout, `Upload state: ${status.upload_state}`);
790
- writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
791
- const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
792
- const { agentRules, autostart } = setup;
793
- if (!setup.ok) {
794
- writeOnboardAutostartBlocker(io, autostart);
795
- return finishOnboardRun(command, installEvents, io, 1);
796
- }
797
- writeLine(io.stdout, "PASS: Tower is set up and collecting.");
798
- writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
799
- pair,
800
- status,
801
- sync,
802
- agentRules,
803
- autostart,
804
- initialSyncOk: true,
805
- backfill: completedBackfill,
806
- });
807
- return finishOnboardRun(command, installEvents, io, 0);
808
- }
809
- /**
810
- * Every uncaught error from either onboarding arm lands here. `status` is
811
- * always recomputed fresh against whichever root got furthest, never trusted
812
- * from before the throw — the arms already recompute it themselves on their
813
- * own success paths, so nothing upstream is lost by not threading it through.
814
- */
815
- async function handleOnboardFailure(error, command, rootsResult, install, pair, sync, installEvents, io) {
816
- const message = errorMessage(error);
817
- const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
818
- const status = statusRoot
819
- ? await inspectLocalCollectorStatus({
820
- homeDir: command.homeDir,
821
- repoRoot: statusRoot,
822
- branch: command.branch,
823
- }).catch(() => null)
824
- : null;
825
- const blocker = classifyOnboardBlocker(message);
826
- addOnboardFailureEvent(installEvents, blocker);
827
- const nextStep = nextStepForOnboardBlocker(blocker, command);
828
- if (command.json) {
829
- writeOnboardJson(io, onboardJsonPayload("blocked", command, install, pair, sync, status, rootsResult, {
830
- blocker,
831
- message,
832
- next_step: nextStep,
833
- }));
834
- }
835
- else {
836
- writeLine(io.stderr, `BLOCKED: ${message}`);
837
- writeLine(io.stderr, `Next: ${nextStep}`);
838
- }
839
- return finishOnboardRun(command, installEvents, io, 1);
840
- }
841
- async function runMultiRepoOnboard(command, io, worktrees) {
842
- if (!command.json) {
843
- writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
844
- }
845
- const collectionRoots = await collectionRootConsentAliases(command.collectionRoots ??
846
- (command.repoRoot ? [command.repoRoot] : []));
847
- const run = await runAttributedWorktreeSync({
848
- homeDir: command.homeDir,
849
- dashboardUrl: command.dashboardUrl,
850
- branch: command.branch,
851
- activeTicketId: command.activeTicketId,
852
- collectionRoots,
853
- startContexts: true,
854
- worktrees,
855
- fetchImpl: io.fetch,
856
- });
857
- const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
858
- const collectionRunStatus = attributedSyncRunStatus(run);
859
- if (!command.json) {
860
- for (const [index, outcome] of run.outcomes.entries()) {
861
- const row = results[index];
862
- if (!row)
863
- continue;
864
- const uploaded = outcome.sync.status === "uploaded";
865
- writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "SYNCED" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
866
- }
867
- writeLine(io.stdout, "4/5 Parent worktree sync complete.");
868
- writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
869
- writeAgentSessionSummary(io, run.summary);
870
- if (run.ok) {
871
- writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
872
- }
873
- else {
874
- writeLine(io.stderr, `BLOCKED: Tower collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
875
- }
876
- writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
877
- }
878
- return { ok: run.ok, results, codex_sessions: run.summary };
879
- }
880
- async function runLogin(command, io) {
881
- // Standalone `cockpit login` must work on a fresh machine: bootstrap a
882
- // minimal rootless config when onboard/install has not run yet, instead of
883
- // failing on the missing config file.
884
- const { config, paths, created } = await ensureLocalCollectorConfig({
885
- homeDir: command.homeDir,
886
- dashboardUrl: command.dashboardUrl,
887
- });
888
- const dashboardUrl = command.dashboardUrl ?? config.dashboard_url;
889
- if (created && !command.json) {
890
- writeLine(io.stdout, `First login on this machine; wrote collector config: ${paths.config_file}`);
891
- }
892
- const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
893
- const pairingAccessToken = await requestPairingAccessToken({
894
- dashboardUrl,
895
- email: claimedOwnerEmail,
896
- noAuth: command.noAuth,
897
- json: command.json,
898
- }, io);
899
- const result = await pairLocalCollectorWithAuthFallback({
900
- homeDir: command.homeDir,
901
- dashboardUrl,
902
- claimedOwnerEmail,
903
- deviceName: command.deviceName,
904
- pollIntervalMs: command.pollIntervalMs,
905
- timeoutMs: command.timeoutMs,
906
- pairingAccessToken,
907
- fetch: io.fetch,
908
- onPairStarted: command.json
909
- ? undefined
910
- : (request) => {
911
- writeLine(io.stdout, "Tower device pairing started.");
912
- writeLine(io.stdout, `Open: ${request.approve_url}`);
913
- writeLine(io.stdout, `Code: ${request.user_code}`);
914
- writeLine(io.stdout, "Waiting for dashboard approval...");
915
- },
916
- }, io);
917
- if (command.json) {
918
- writeLine(io.stdout, JSON.stringify(result, null, 2));
919
- return 0;
920
- }
921
- writeLine(io.stdout, "Tower collector paired.");
922
- writeLine(io.stdout, `Session: ${result.session_file}`);
923
- writeLine(io.stdout, `User: ${result.session.email ?? result.session.auth_subject_id}`);
924
- writeLine(io.stdout, `Device: ${result.session.device_name ?? result.session.device_id ?? "unknown"}`);
925
- writeLine(io.stdout, config.default_repo_paths.length > 0
926
- ? "Next: run `cockpit start` inside the repo."
927
- : "Next: run `cockpit onboard` from your repo root so Tower knows which repos to collect.");
928
- return 0;
929
- }
930
- function writePairingInstructions(io, request) {
931
- writeLine(io.stdout, "2/5 Device pairing started.");
932
- writeLine(io.stdout, `Open: ${request.approve_url}`);
933
- writeLine(io.stdout, `Code: ${request.user_code}`);
934
- writeLine(io.stdout, "Waiting for dashboard approval...");
935
- }
936
- function onboardResult(resultStatus, command, install, pair, sync, status) {
937
- return {
938
- status: resultStatus,
939
- dashboard_url: command.dashboardUrl,
940
- ticket_id: sync?.ticket_id ?? command.activeTicketId ?? null,
941
- work_context_id: sync?.work_context_id ?? status?.work_context_id ?? null,
942
- config_file: install?.paths.config_file ?? status?.config_file ?? null,
943
- session_file: install?.paths.session_file ?? status?.session_file ?? null,
944
- collection_roots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
945
- paired_user: pair?.session.email ?? pair?.session.auth_subject_id ?? null,
946
- paired_device: pair?.session.device_name ?? pair?.session.device_id ?? null,
947
- upload_status: sync?.status ?? null,
948
- upload_state: status?.upload_state ?? null,
949
- event_count: sync?.event_count ?? null,
950
- source_scan_count: sync?.source_scan_count ?? null,
951
- risk_flag_count: sync?.risk_flag_count ?? null,
952
- pending_upload_count: status?.pending_upload_count ?? null,
953
- last_upload_success_at: status?.last_upload_success_at ?? null,
954
- next_dashboard_path: `${command.dashboardUrl}/my-work`,
955
- };
956
- }
957
- function classifyOnboardBlocker(message) {
958
- if (message.includes(COLLECTION_ROOT_REQUIRED))
959
- return COLLECTION_ROOT_REQUIRED;
960
- if (/ticket/i.test(message))
961
- return "ticket";
962
- if (/pair|paired|approval|expired|revoked/i.test(message))
963
- return "device_pairing";
964
- if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message))
965
- return "network_or_ingest";
966
- if (/config|install/i.test(message))
967
- return "install";
968
- if (/context|start/i.test(message))
969
- return "work_context";
970
- if (/bound|binding/i.test(message))
971
- return "ticket_binding";
972
- return "unknown";
973
- }
974
- function nextStepForOnboardBlocker(blocker, options = {}) {
975
- switch (blocker) {
976
- case COLLECTION_ROOT_REQUIRED:
977
- return missingCollectionRootMessage(options);
978
- case "ticket":
979
- case "ticket_binding":
980
- return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
981
- case "device_pairing":
982
- return "Ask Edward to approve this machine in the dashboard under Ambient -> Collector approvals, then run `cockpit onboard` again.";
983
- case "network_or_ingest":
984
- return "Check dashboard URL/network, then run `cockpit sync --json` or rerun `cockpit onboard`.";
985
- case "install":
986
- return "Rerun `cockpit onboard` from the repo root; it will reinstall local config.";
987
- case "work_context":
988
- return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
989
- default:
990
- return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
991
- }
992
- }
993
- /**
994
- * `cockpit logout` removes the local session file. `--revoke` also tells Tower
995
- * to end the device's token (BLI-3461).
996
- *
997
- * Those are two different facts, and the difference matters when a laptop is
998
- * being handed back: without the revoke the machine stops collecting but the
999
- * token it held is still one the server would honour. The revoke is attempted
1000
- * FIRST, because it needs the token the local logout is about to delete, and a
1001
- * revoke that fails does not stop the local logout — the person asked to be
1002
- * signed out of this machine, and they get that either way. The outcome of each
1003
- * half is reported separately rather than folded into one "ok".
1004
- */
1005
- async function runLogout(command, io) {
1006
- const revoke = command.revoke ? await revokeThisDevice(command, io) : null;
1007
- const result = await logoutLocalCollector({ homeDir: command.homeDir });
1008
- writeLine(io.stderr, `[logout] done ${JSON.stringify({
1009
- local_session: result.removed ? "removed" : "absent",
1010
- revoke: revoke ? revoke.outcome : "not_requested",
1011
- })}`);
1012
- if (command.json) {
1013
- writeLine(io.stdout, JSON.stringify({ ...result, revoke: revoke ?? { outcome: "not_requested" } }, null, 2));
1014
- return revoke && revoke.outcome !== "revoked" ? 1 : 0;
1015
- }
1016
- writeLine(io.stdout, result.removed
1017
- ? "Tower collector session removed."
1018
- : "No Tower collector session found.");
1019
- if (revoke) {
1020
- writeLine(io.stdout, revoke.outcome === "revoked"
1021
- ? "This device's Tower token is revoked."
1022
- : `This device's Tower token was NOT revoked: ${revoke.detail}`);
1023
- }
1024
- return revoke && revoke.outcome !== "revoked" ? 1 : 0;
1025
- }
1026
- /**
1027
- * Ends this device's own session server-side.
1028
- *
1029
- * The route gates the device arm on `self_revoke_only`, so the device id is
1030
- * read from the paired session rather than accepted as an argument — there is
1031
- * no way to spell "revoke somebody else's laptop" here, and that is deliberate.
1032
- */
1033
- async function revokeThisDevice(command, io) {
1034
- let tower;
1035
- let deviceId;
1036
- try {
1037
- const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
1038
- deviceId = session.device_id;
1039
- tower = await openTower("logout", command, io);
1040
- }
1041
- catch (error) {
1042
- return { outcome: "no_paired_session", detail: errorMessage(error) };
1043
- }
1044
- const result = await callTower(tower, {
1045
- path: `/api/ambient/devices/${encodeURIComponent(deviceId)}/revoke`,
1046
- method: "POST",
1047
- label: "logout revoke",
1048
- body: { reason: "Signed out from the device" },
1049
- });
1050
- if (!result.ok) {
1051
- return { outcome: result.reason, detail: result.detail };
1052
- }
1053
- const body = asRecord(result.body);
1054
- return { outcome: "revoked", detail: String(asRecord(body.device).revoked_at ?? "") };
1055
- }
1056
- async function runStart(command, io) {
1057
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
1058
- if (worktrees.length > 1) {
1059
- const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
1060
- homeDir: command.homeDir,
1061
- repoRoot: worktree.repo_root,
1062
- branch: command.branch,
1063
- activeTicketId: command.activeTicketId,
1064
- clearTicket: command.clearTicket,
1065
- topicLabel: command.topicLabel,
1066
- topicSummaryRedacted: command.topicSummaryRedacted,
1067
- workIntent: command.workIntent,
1068
- workPhase: command.workPhase,
1069
- intentSource: command.intentSource,
1070
- intentConfidence: command.intentConfidence,
1071
- operatorId: command.operatorId,
1072
- sessionId: command.sessionId,
1073
- })));
1074
- if (command.json) {
1075
- writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
1076
- return 0;
1077
- }
1078
- writeLine(io.stdout, `Tower parent work context active for ${contexts.length} worktree(s).`);
1079
- for (const context of contexts) {
1080
- writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
1081
- }
1082
- return 0;
1083
- }
1084
- const context = await startLocalWorkContext(command);
1085
- if (command.json) {
1086
- writeLine(io.stdout, JSON.stringify(context, null, 2));
1087
- return 0;
1088
- }
1089
- writeLine(io.stdout, "Tower work context active.");
1090
- writeLine(io.stdout, `Repo: ${context.repo}`);
1091
- writeLine(io.stdout, `Branch: ${context.branch}`);
1092
- writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
1093
- if (context.topic_label || context.work_intent || context.work_phase) {
1094
- writeLine(io.stdout, `Topic: ${context.topic_label ?? "unlabeled"} · ${context.work_intent ?? "unknown"} · ${context.work_phase ?? "unknown"}`);
1095
- }
1096
- writeLine(io.stdout, `Context: ${context.work_context_id}`);
1097
- return 0;
1098
- }
1099
- async function runSync(command, io) {
1100
- const paths = getCollectorRuntimePaths(command.homeDir);
1101
- // BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
1102
- // rotated them before, and two months of a 15-minute tick left 211 MB of
1103
- // sync.log on the reference Mac. Best-effort by construction — a rotation
1104
- // problem is its own log line, never a reason collection does not run.
1105
- await rotateCollectorLogsBestEffort(paths);
1106
- const config = await readLocalCollectorConfig(paths).catch(() => null);
1107
- const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
1108
- const minCliVersionAtStart = await reportInstallEventsBestEffort({
1109
- homeDir: command.homeDir,
1110
- dashboardUrl,
1111
- command: "sync",
1112
- events: [{ step: "sync_started", status: "ok" }],
1113
- json: command.json,
1114
- io,
1115
- });
1116
- try {
1117
- const result = await runSyncWithHealthReceipt(command, io);
1118
- // BLI-3551: every tick checks in, including one that collected nothing.
1119
- // This is the only writer of `last_seen_at` that does not need an envelope,
1120
- // so it is what separates a quiet machine from a dead one.
1121
- await sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat);
1122
- const minCliVersion = await reportInstallEventsBestEffort({
1123
- homeDir: command.homeDir,
1124
- dashboardUrl,
1125
- command: "sync",
1126
- events: [result.completion],
1127
- json: command.json,
1128
- io,
1129
- });
1130
- // BLI-2601: self-update runs only after collection's own outcome above is
1131
- // already decided and reported, win or lose. See the function doc.
1132
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
1133
- await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
1134
- return result.exitCode;
1135
- }
1136
- catch (error) {
1137
- const errorCode = classifySyncHealthError(error);
1138
- // A machine whose sync THREW is still alive, and that is worth knowing —
1139
- // a device that stops checking in entirely is a different problem from one
1140
- // checking in with a failure every fifteen minutes.
1141
- await sendSyncHeartbeat(command, io, dashboardUrl, {
1142
- status: "fail",
1143
- reason: errorCode,
1144
- });
1145
- const minCliVersion = await reportInstallEventsBestEffort({
1146
- homeDir: command.homeDir,
1147
- dashboardUrl,
1148
- command: "sync",
1149
- events: [
1150
- {
1151
- step: "sync_complete",
1152
- status: "fail",
1153
- error_code: errorCode,
1154
- error_detail: redactedSyncErrorDetail(error),
1155
- },
1156
- ],
1157
- json: command.json,
1158
- io,
1159
- });
1160
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
1161
- await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
1162
- throw error;
1163
- }
1164
- }
1165
- /**
1166
- * The tick's check-in (BLI-3551).
1167
- *
1168
- * Resolving the roots is best-effort on purpose: a machine with NO approved
1169
- * root is exactly the machine whose silence needs explaining, so it still
1170
- * checks in — with an empty root list, which is itself the finding.
1171
- */
1172
- async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
1173
- const roots = await resolveSyncCollectionRoots(command).catch(() => []);
1174
- await sendCollectorHeartbeatBestEffort({
1175
- homeDir: command.homeDir,
1176
- dashboardUrl,
1177
- roots,
1178
- facts,
1179
- io,
1180
- }).catch((error) => {
1181
- // The sender already swallows everything it knows about; this is the net
1182
- // for anything it does not, because a heartbeat must never fail a sync.
1183
- console.error("[heartbeat] the check-in threw and was dropped", JSON.stringify({ reason: "heartbeat_threw", ...describeError(error) }));
1184
- return false;
1185
- });
1186
- }
1187
- /**
1188
- * BLI-2721: after the tick's collection and self-update are done and
1189
- * reported, repair a broken/legacy autostart registration in place (Windows
1190
- * only — see autostart-self-heal.ts for why macOS is excluded). Every error
1191
- * path is swallowed like the self-update's: heal outcomes are their own
1192
- * receipts, never a sync failure.
1193
- */
1194
- async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
1195
- let result;
1196
- try {
1197
- const rawExec = io.exec;
1198
- if (!rawExec) {
1199
- // BLI-3483: this was a bare `return`. On Windows the self-heal is the
1200
- // only thing that puts a broken scheduler back, so abandoning it here
1201
- // meant a machine could stop collecting forever and leave no receipt
1202
- // anywhere — the exact shape the fleet contract forbids. The packed CLI
1203
- // always supplies a runner (`commands/cli-io.ts`), so this fires only for
1204
- // an embedder that built its own `io`; it costs one line either way.
1205
- console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
1206
- reason: "runner_unavailable",
1207
- platform: process.platform,
1208
- next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
1209
- }));
1210
- result = {
1211
- status: "skipped",
1212
- reason: "runner_unavailable",
1213
- detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
1214
- };
1215
- await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
1216
- return;
1217
- }
1218
- const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
1219
- const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
1220
- result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
1221
- homeDir: command.homeDir,
1222
- repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
1223
- dashboardUrl: command.dashboardUrl,
1224
- exec,
1225
- });
1226
- }
1227
- catch (error) {
1228
- result = {
1229
- status: "fail",
1230
- reason: "autostart_self_heal_threw",
1231
- detail: redactedSyncErrorDetail(error),
1232
- };
1233
- }
1234
- // Steady state (healthy, absent, non-Windows, no roots) and the daily
1235
- // throttle are silent; an actual repair attempt reports either way.
1236
- if (!result || result.reason === "repair_throttled_recent_attempt")
1237
- return;
1238
- await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
1239
- }
1240
- /** One receipt for the repair, whichever branch above produced the outcome. */
1241
- async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
1242
- await reportInstallEventsBestEffort({
1243
- homeDir: command.homeDir,
1244
- dashboardUrl,
1245
- command: "sync",
1246
- events: [
1247
- {
1248
- // Windows repairs in place and keeps the name already in the receipts
1249
- // and the runbook; the macOS path only SCHEDULES a detached repair, so
1250
- // it reports under its own step (BLI-3553).
1251
- step: result.step ?? "autostart_repair",
1252
- status: result.status,
1253
- ...(result.status === "ok" ? {} : { error_code: result.reason }),
1254
- ...(result.detail ? { error_detail: result.detail } : {}),
1255
- },
1256
- ],
1257
- json: command.json,
1258
- io,
1259
- });
1260
- }
1261
- /**
1262
- * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
1263
- * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
1264
- * runs AFTER `runSync` has already decided and reported collection's own
1265
- * outcome above — a stuck or failing self-update can never block or delay
1266
- * collection, and a collection failure never blocks the chance to
1267
- * self-update. Every error path here is swallowed on purpose: a failure is
1268
- * reported as its own named `update` receipt, never surfaced as a `sync`
1269
- * failure or thrown from this function.
1270
- */
1271
- async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
1272
- let event;
1273
- try {
1274
- event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
1275
- }
1276
- catch (error) {
1277
- // The throttle/probe/install machinery below is defensive already; this
1278
- // is the last-resort net so an update crash truly cannot touch the sync
1279
- // result above.
1280
- event = {
1281
- step: "update",
1282
- status: "fail",
1283
- error_code: "self_update_threw",
1284
- error_detail: redactedSyncErrorDetail(error),
1285
- };
1286
- }
1287
- if (!event)
1288
- return;
1289
- await reportInstallEventsBestEffort({
1290
- homeDir: command.homeDir,
1291
- dashboardUrl,
1292
- command: "update",
1293
- events: [event],
1294
- json: command.json,
1295
- io,
1296
- });
1297
- }
1298
- async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
1299
- const rawExec = io.exec;
1300
- if (!rawExec) {
1301
- // Only the real production `defaultIo()` supplies a process runner. A
1302
- // caller that omitted one gets a silent no-op rather than this reaching
1303
- // for a real npm binary it was never given — never observed in
1304
- // production, where `defaultIo()` always sets `exec`.
1305
- return null;
1306
- }
1307
- // Every spawn in the scheduled path carries the running node's bin dir on
1308
- // PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
1309
- // this; the scheduler's stripped environment does.
1310
- const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
1311
- const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
1312
- const scheduledIo = { ...io, exec };
1313
- const paths = getCollectorRuntimePaths(command.homeDir);
1314
- const result = await runScheduledSelfUpdate(paths, {
1315
- exec,
1316
- currentVersion: LOCAL_COLLECTOR_VERSION,
1317
- install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
1318
- }, { env: io.env, minVersion: minCliVersion });
1319
- return scheduledSelfUpdateInstallEvent(result);
1320
- }
1321
- async function attemptScheduledSelfUpdateInstall(io, tag) {
1322
- try {
1323
- // Reuses the exact npm-install machinery `cockpit doctor`'s
1324
- // `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
1325
- // knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
1326
- // this call never re-execs — see runScheduledSelfUpdate's doc comment.
1327
- await runSelfUpdate(io, { json: true, tag });
1328
- return { ok: true };
1329
- }
1330
- catch (error) {
1331
- if (!(error instanceof SelfUpdateError))
1332
- throw error;
1333
- return { ok: false, eacces: error.eacces };
1334
- }
1335
- }
1336
- function scheduledSelfUpdateInstallEvent(result) {
1337
- // The steady-state "already checked today" case is a pure no-op; reporting
1338
- // it would post a receipt on ~95 of every 96 sync ticks for no new
1339
- // information. Only a real attempt (ok, fail, or an explicit disable)
1340
- // produces a receipt.
1341
- if (result.reason === "throttled_recent_attempt")
1342
- return null;
1343
- // A forced attempt names its trigger in the receipt either way, so the
1344
- // ledger can tell "converged on the daily cadence" from "the floor pulled
1345
- // this machine forward" (BLI-2678).
1346
- const forcedDetail = result.forced && result.min_version
1347
- ? `forced_min_version ${result.min_version}`
1348
- : null;
1349
- if (result.status === "ok") {
1350
- // BLI-3551: this used to be `update ok` with an empty detail unless the
1351
- // floor forced it. One machine posted that receipt daily for nine releases
1352
- // while sitting on 0.2.37, and nobody could tell "already current" from
1353
- // "installed something" from "npm answered nothing" — three different
1354
- // situations wearing one word. The success branch names itself now.
1355
- const okDetail = [
1356
- forcedDetail,
1357
- result.reason === "updated" && result.previous_version && result.installed_version
1358
- ? `installed ${result.previous_version}→${result.installed_version}`
1359
- : result.reason,
1360
- result.target_version ? `target ${result.target_version}` : null,
1361
- ]
1362
- .filter((part) => Boolean(part))
1363
- .join("; ");
1364
- return {
1365
- step: "update",
1366
- status: "ok",
1367
- ...(okDetail ? { error_detail: okDetail } : {}),
1368
- };
1369
- }
1370
- const detail = [
1371
- forcedDetail,
1372
- result.target_version ? `target ${result.target_version}` : null,
1373
- result.installed_version ? `installed ${result.installed_version}` : null,
1374
- ]
1375
- .filter((part) => Boolean(part))
1376
- .join("; ");
1377
- return {
1378
- step: "update",
1379
- status: result.status,
1380
- error_code: result.reason,
1381
- ...(detail ? { error_detail: detail } : {}),
1382
- };
1383
- }
1384
- async function runSyncWithHealthReceipt(command, io) {
1385
- const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
1386
- if (backfillLock.held) {
1387
- if (command.json) {
1388
- writeLine(io.stdout, JSON.stringify({
1389
- status: "live_sync_paused_during_backfill",
1390
- reason: "live sync paused during backfill",
1391
- held_since: backfillLock.held_since,
1392
- collection_complete: false,
1393
- upload_state: "not_uploaded",
1394
- retryable: true,
1395
- }, null, 2));
1396
- }
1397
- else {
1398
- writeLine(io.stdout, "Pausing normal sync while it catches up on old sessions.");
1399
- }
1400
- return {
1401
- exitCode: 0,
1402
- completion: {
1403
- step: "sync_complete",
1404
- status: "skipped",
1405
- error_code: "live_sync_paused_during_backfill",
1406
- },
1407
- heartbeat: {
1408
- status: "skipped",
1409
- reason: "live_sync_paused_during_backfill",
1410
- },
1411
- };
1412
- }
1413
- // Single-flight: a launchd timer and a manual sync must not interleave the
1414
- // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1415
- const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
1416
- if (!lock.acquired) {
1417
- if (command.json) {
1418
- writeLine(io.stdout, JSON.stringify({
1419
- status: "sync_already_running",
1420
- reason: "another sync owns the collection lock",
1421
- held_since: lock.held_since,
1422
- collection_complete: false,
1423
- upload_state: "not_uploaded",
1424
- retryable: true,
1425
- }, null, 2));
1426
- }
1427
- else {
1428
- writeLine(io.stdout, "Tower sync already running; skipping this run.");
1429
- }
1430
- return {
1431
- exitCode: 0,
1432
- completion: {
1433
- step: "sync_complete",
1434
- status: "skipped",
1435
- error_code: "sync_already_running",
1436
- },
1437
- heartbeat: { status: "skipped", reason: "sync_already_running" },
1438
- };
1439
- }
1440
- try {
1441
- const { exitCode, failureReasons, failureRecords, notice, sessionsObserved, sessionsOutsideRoot, } = await runSyncLocked(command, io);
1442
- const counts = {
1443
- sessionsObserved,
1444
- sessionsOutsideRoot,
1445
- };
1446
- if (exitCode === 0) {
1447
- // BLI-3551: an `ok` tick can still have something to say. `nothing_in_root`
1448
- // is the receipt that separates "this machine is alive and its operator
1449
- // works outside the approved roots" from "this machine is dead", which
1450
- // until now looked identical from the dashboard.
1451
- return {
1452
- exitCode,
1453
- completion: {
1454
- step: "sync_complete",
1455
- status: "ok",
1456
- ...(notice ? { error_detail: notice } : {}),
1457
- },
1458
- heartbeat: { status: "ok", reason: notice, ...counts },
1459
- };
1460
- }
1461
- // A sync that fails by exit code says exactly as much as one that throws.
1462
- // It used to say `sync_failed` and nothing else, so 100% of recorded
1463
- // failure rows carried a null detail and the real reason was reachable only
1464
- // by running `cockpit status` on the machine itself (BLI-2526).
1465
- const reasonText = failureReasons.join("; ");
1466
- // The bucket comes from the records the deciding branches wrote, not from
1467
- // this sentence (BLI-3551). The sentence is still the detail.
1468
- const errorCode = classifySyncFailureRecords(failureRecords);
1469
- return {
1470
- exitCode,
1471
- completion: {
1472
- step: "sync_complete",
1473
- status: "fail",
1474
- error_code: errorCode,
1475
- error_detail: redactedSyncErrorDetail(reasonText),
1476
- },
1477
- heartbeat: { status: "fail", reason: errorCode, ...counts },
1478
- };
1479
- }
1480
- finally {
1481
- await lock.handle.release();
1482
- }
1483
- }
1484
- /**
1485
- * Turn a finished run into an exit code and the reasons behind it.
1486
- *
1487
- * One place, so a future return path cannot reintroduce a code with no reason.
1488
- * The reasons come from the run itself — the code that decided `ok` is false is
1489
- * the only code that knows why.
1490
- */
1491
- function syncResult(run) {
1492
- return {
1493
- exitCode: run.ok ? 0 : 1,
1494
- failureReasons: run.ok ? [] : run.failure_reasons,
1495
- failureRecords: run.ok ? [] : run.failure_records,
1496
- notice: run.notice,
1497
- sessionsObserved: run.sessions_observed,
1498
- sessionsOutsideRoot: run.sessions_outside_root,
1499
- };
1500
- }
1501
- /**
1502
- * The sync runbook: resolve which roots to collect, dedup staged packs before
1503
- * touching anything else, discover worktrees, sync them, then report — one of
1504
- * three shapes depending on how many worktrees came back. The three shapes
1505
- * share nothing but `dedup`, so each gets its own step function below rather
1506
- * than one branchy body.
1507
- */
1508
- async function runSyncLocked(command, io) {
1509
- const collectionRoots = await resolveSyncCollectionRoots(command);
1510
- // Before anything is collected: collapse byte-identical staged packs. It runs
1511
- // first, unconditionally and unthrottled, because a machine that already
1512
- // holds 559 copies of one rollout needs the disk back before it stages
1513
- // anything else (BLI-3066). Safe by construction — a duplicate is identical
1514
- // by content hash to the survivor.
1515
- const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
1516
- if (!dedup.skipped && dedup.removed_dirs > 0) {
1517
- writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
1518
- }
1519
- const worktrees = await discoverCommandWorktrees(collectionRoots, {
1520
- maxDepth: command.maxDepth,
1521
- maxRepos: command.maxRepos,
1522
- homeDir: command.homeDir,
1523
- allowEmpty: true,
1524
- }, io);
1525
- const run = await runAttributedWorktreeSync({
1526
- homeDir: command.homeDir,
1527
- dashboardUrl: command.dashboardUrl,
1528
- collectionRoots,
1529
- startContexts: false,
1530
- worktrees,
1531
- fetchImpl: io.fetch,
1532
- });
1533
- if (run.outcomes.length > 1) {
1534
- return reportMultiRepoSync(command, io, run, dedup);
1535
- }
1536
- // Zero worktrees is a legitimate steady state, not a failure: an approved
1537
- // root can hold no git repos, and sessions upload independently of
1538
- // worktrees (session-first, BLI-2581). This used to throw "Sync produced no
1539
- // result", which painted ~90 false-red sync_failed receipts per day on one
1540
- // fleet machine with a single empty root and taught people to ignore
1541
- // sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
1542
- if (run.outcomes.length === 0) {
1543
- return reportNoWorktreeSync(command, io, run, dedup);
1544
- }
1545
- return reportSingleRepoSync(command, io, run, dedup);
1546
- }
1547
- async function reportMultiRepoSync(command, io, run, dedup) {
1548
- const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1549
- const collectionRunStatus = attributedSyncRunStatus(run);
1550
- const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1551
- if (command.json) {
1552
- writeLine(io.stdout, JSON.stringify({
1553
- mode: "multi_repo",
1554
- status: collectionRunStatus,
1555
- collection_complete: run.ok,
1556
- results: run.outcomes.map((outcome) => outcome.sync),
1557
- repos: rows,
1558
- codex_sessions: run.summary,
1559
- raw_evidence_gc: gc,
1560
- raw_evidence_dedup: dedup,
1561
- }, null, 2));
1562
- return syncResult(run);
1563
- }
1564
- writeLine(run.ok ? io.stdout : io.stderr, `Tower parent sync ${collectionRunStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
1565
- for (const outcome of run.outcomes) {
1566
- const { worktree, sync } = outcome;
1567
- const uploaded = sync.status === "uploaded";
1568
- const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1569
- writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
1570
- }
1571
- writeAgentSessionSummary(io, run.summary);
1572
- if (gc && !gc.skipped)
1573
- writeLine(io.stdout, rawEvidenceGcSummary(gc));
1574
- return syncResult(run);
1575
- }
1576
- async function reportNoWorktreeSync(command, io, run, dedup) {
1577
- const collectionRunStatus = attributedSyncRunStatus(run);
1578
- const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1579
- if (command.json) {
1580
- writeLine(io.stdout, JSON.stringify({
1581
- mode: "no_worktrees",
1582
- status: collectionRunStatus,
1583
- collection_complete: run.ok,
1584
- ...(run.notice ? { notice: run.notice } : {}),
1585
- codex_sessions: run.summary,
1586
- raw_evidence_gc: gc,
1587
- raw_evidence_dedup: dedup,
1588
- }, null, 2));
1589
- return syncResult(run);
1590
- }
1591
- writeLine(run.ok ? io.stdout : io.stderr, `Tower sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
1592
- if (run.notice) {
1593
- // Says out loud what the receipt now says to the dashboard: the sessions
1594
- // this machine ran were all outside the folders it is allowed to look at.
1595
- writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). Nothing was collected, and nothing is broken.`);
1596
- }
1597
- writeAgentSessionSummary(io, run.summary);
1598
- if (gc && !gc.skipped)
1599
- writeLine(io.stdout, rawEvidenceGcSummary(gc));
1600
- return syncResult(run);
1601
- }
1602
- async function reportSingleRepoSync(command, io, run, dedup) {
1603
- const result = run.outcomes[0]?.sync;
1604
- if (!result) {
1605
- throw new Error("Sync produced no result for the repo worktree.");
1606
- }
1607
- const collectionRunStatus = attributedSyncRunStatus(run);
1608
- const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1609
- if (command.json) {
1610
- writeLine(io.stdout, JSON.stringify({
1611
- ...result,
1612
- status: collectionRunStatus,
1613
- collection_complete: run.ok,
1614
- codex_sessions: run.summary,
1615
- raw_evidence_gc: gc,
1616
- raw_evidence_dedup: dedup,
1617
- }, null, 2));
1618
- return syncResult(run);
1619
- }
1620
- if (run.ok) {
1621
- writeLine(io.stdout, "Tower uploaded this session.");
1622
- writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
1623
- writeLine(io.stdout, `Context: ${result.work_context_id}`);
1624
- writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
1625
- writeLine(io.stdout, `Things recorded: ${result.event_count}`);
1626
- writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
1627
- writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1628
- writeLine(io.stdout, rawEvidenceSyncLine(result));
1629
- writeAgentSessionSummary(io, run.summary);
1630
- writeLine(io.stdout, cursorStatusLine(result));
1631
- if (gc && !gc.skipped)
1632
- writeLine(io.stdout, rawEvidenceGcSummary(gc));
1633
- return syncResult(run);
1634
- }
1635
- if (result.status === "uploaded") {
1636
- writeLine(io.stderr, "Tower uploaded, but some sessions did not make it. Run `cockpit sync` again.");
1637
- writeAgentSessionSummary(io, run.summary);
1638
- return syncResult(run);
1639
- }
1640
- writeLine(io.stderr, "Tower could not upload. It saved a note to retry and will try again on the next sync.");
1641
- writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
1642
- writeLine(io.stderr, `Failure: ${result.failure_reason}`);
1643
- writeLine(io.stderr, `Retry: ${result.retry_command}`);
1644
- return syncResult(run);
1645
- }
1646
- async function resolveSyncCollectionRoots(command) {
1647
- const explicitRoots = normalizeCollectionRoots(command.repoRoot ? [command.repoRoot] : []);
1648
- if (explicitRoots.length > 0) {
1649
- return collectionRootConsentAliases(explicitRoots);
1650
- }
1651
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths(command.homeDir)).catch(() => null);
1652
- const savedRoots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
1653
- if (savedRoots.length > 0) {
1654
- return collectionRootConsentAliases(savedRoots);
1655
- }
1656
- throw new CollectionRootRequiredError(`no explicit or saved collection root is available.`);
1657
- }
1658
- async function runAnalyze(command, io) {
1659
- const syncStdout = [];
1660
- const syncStderr = [];
1661
- const syncIo = {
1662
- ...io,
1663
- stdout: bufferedWritable(syncStdout),
1664
- stderr: bufferedWritable(syncStderr),
1665
- };
1666
- const syncCommand = {
1667
- ...command,
1668
- kind: "sync",
1669
- json: true,
1670
- };
1671
- const syncExitCode = await runSync(syncCommand, syncIo);
1672
- const syncOutput = parseCapturedJson(syncStdout);
1673
- if (syncExitCode !== 0 || !isUploadedSyncResult(syncOutput)) {
1674
- if (command.json) {
1675
- writeLine(io.stdout, JSON.stringify({
1676
- status: syncExitCode === 0 ? "sync_not_completed" : "sync_failed",
1677
- sync: syncOutput,
1678
- error: syncStderr.join("").trim()
1679
- || (syncExitCode === 0
1680
- ? "Tower sync did not upload fresh evidence."
1681
- : "Tower sync failed."),
1682
- }, null, 2));
1683
- }
1684
- else {
1685
- replayCaptured(io.stderr, syncStderr);
1686
- writeLine(io.stderr, syncExitCode === 0
1687
- ? "Analysis was not queued because a fresh upload did not complete."
1688
- : "Analysis was not queued because the latest upload failed.");
1689
- }
1690
- return syncExitCode === 0 ? 1 : syncExitCode;
1691
- }
1692
- const paths = getCollectorRuntimePaths(command.homeDir);
1693
- const session = await readLocalCollectorSessionFile(paths).catch((error) => {
1694
- // "Not signed in" is the right sentence for an absent session file and the
1695
- // wrong one for a corrupt one, which `cockpit login` will not repair
1696
- // (BLI-3238).
1697
- if (!isMissingFileFailure(error)) {
1698
- console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
1699
- reason: "session_file_unusable",
1700
- ...describeError(error),
1701
- }));
1702
- }
1703
- throw new Error("Tower is not signed in. Run `cockpit onboard` or `cockpit login` first.");
1704
- });
1705
- const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
1706
- const response = await io.fetch(`${dashboardUrl}/api/ambient/analyze`, {
1707
- method: "POST",
1708
- headers: {
1709
- authorization: `Bearer ${session.device_token}`,
1710
- "content-type": "application/json",
1711
- },
1712
- body: "{}",
1713
- });
1714
- const body = await readAnalyzeApiResponse(response);
1715
- if (!response.ok) {
1716
- const message = typeof body.message === "string"
1717
- ? body.message
1718
- : `Analysis request failed with HTTP ${response.status}.`;
1719
- if (command.json) {
1720
- writeLine(io.stdout, JSON.stringify({
1721
- status: "analyze_failed",
1722
- sync: syncOutput,
1723
- http_status: response.status,
1724
- code: typeof body.code === "string" ? body.code : null,
1725
- message,
1726
- retry_after_seconds: typeof body.retry_after_seconds === "number"
1727
- ? body.retry_after_seconds
1728
- : null,
1729
- }, null, 2));
1730
- }
1731
- else {
1732
- replayCaptured(io.stderr, syncStderr);
1733
- writeLine(io.stderr, `Analysis was not queued: ${message}`);
1734
- }
1735
- return 1;
1736
- }
1737
- const jobId = typeof body.job?.id === "string" ? body.job.id : null;
1738
- const jobStatus = typeof body.job?.status === "string" ? body.job.status : "pending";
1739
- if (command.json) {
1740
- writeLine(io.stdout, JSON.stringify({
1741
- status: "queued",
1742
- sync: syncOutput,
1743
- job: body.job ?? null,
1744
- dashboard_url: `${dashboardUrl}/my-work`,
1745
- }, null, 2));
1746
- return 0;
1747
- }
1748
- replayCaptured(io.stderr, syncStderr);
1749
- writeLine(io.stdout, "Tower uploaded your latest work.");
1750
- writeLine(io.stdout, "Tower analysis queued.");
1751
- if (jobId)
1752
- writeLine(io.stdout, `Job: ${jobId}`);
1753
- writeLine(io.stdout, `Status: ${jobStatus === "pending" ? "queued" : jobStatus}`);
1754
- writeLine(io.stdout, `Results: ${dashboardUrl}/my-work`);
1755
- return 0;
1756
- }
1757
- async function readAnalyzeApiResponse(response) {
1758
- const text = await response.text();
1759
- if (!text)
1760
- return {};
1761
- try {
1762
- const value = JSON.parse(text);
1763
- return value && typeof value === "object" ? value : {};
1764
- }
1765
- catch {
1766
- // `{}` erases whatever the analyze endpoint said, including its error, and
1767
- // the operator is left with a job id of `undefined` and no reason. The
1768
- // body is never logged; its shape is what identifies a proxy reply.
1769
- console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
1770
- reason: "response_body_not_json",
1771
- http_status: response.status,
1772
- byte_size: text.length,
1773
- content_type: response.headers.get("content-type") ?? "none",
1774
- }));
1775
- return {};
1776
- }
1777
- }
1778
- function isUploadedSyncResult(value) {
1779
- return Boolean(value
1780
- && typeof value === "object"
1781
- && !Array.isArray(value)
1782
- && value["status"] === "uploaded");
1783
- }
1784
- async function runSyncRawEvidenceGc(command, io) {
1785
- return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
1786
- }
1787
- async function runServe(command, io) {
1788
- const server = createCollectorServer(command);
1789
- await new Promise((resolve) => {
1790
- server.listen(command.port, "127.0.0.1", resolve);
1791
- });
1792
- writeLine(io.stdout, `Tower collector serving on http://127.0.0.1:${command.port}`);
1793
- await new Promise((resolve) => {
1794
- server.on("close", resolve);
1795
- });
1796
- return 0;
1797
- }
1798
- async function runAutostart(command, io) {
1799
- if (command.action === "heal-detached") {
1800
- // BLI-3553: the internal one-shot the scheduled macOS tick spawns. It has
1801
- // its own receipt and its own exit code; nothing else in this function
1802
- // applies to it.
1803
- return runAutostartHealDetached({
1804
- homeDir: command.homeDir,
1805
- dashboardUrl: command.dashboardUrl,
1806
- parentPid: command.parentPid ?? null,
1807
- json: command.json,
1808
- }, io);
1809
- }
1810
- const exec = io.exec ?? defaultExec();
1811
- const repoRoots = command.action === "install" || command.action === "status"
1812
- ? await resolveAutostartRoots(command.homeDir, command.repoRoot)
1813
- : [];
1814
- const result = command.action === "install"
1815
- ? await installAutostartAgent({
1816
- homeDir: command.homeDir,
1817
- repoRoot: repoRoots[0],
1818
- repoRoots,
1819
- dashboardUrl: command.dashboardUrl,
1820
- intervalSeconds: command.intervalSeconds,
1821
- exec,
1822
- })
1823
- : command.action === "uninstall"
1824
- ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
1825
- : await autostartStatus({
1826
- homeDir: command.homeDir,
1827
- repoRoot: repoRoots[0],
1828
- repoRoots,
1829
- dashboardUrl: command.dashboardUrl,
1830
- intervalSeconds: command.intervalSeconds,
1831
- exec,
1832
- });
1833
- if (command.json) {
1834
- writeLine(io.stdout, JSON.stringify(result, null, 2));
1835
- return autostartExitCode(command.action, result);
1836
- }
1837
- writeAutostartResult(io, result);
1838
- return autostartExitCode(command.action, result);
1839
- }
1840
- function autostartExitCode(action, result) {
1841
- if (result.status === "unsupported")
1842
- return 1;
1843
- if (action === "status")
1844
- return result.status === "loaded" ? 0 : 1;
1845
- if (action === "install" && result.loaded === false)
1846
- return 1;
1847
- if (action === "uninstall" && result.status === "not_loaded")
1848
- return 1;
1849
- return 0;
1850
- }
1851
- async function resolveAutostartRoots(homeDir, explicitRoot) {
1852
- if (explicitRoot)
1853
- return [explicitRoot];
1854
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
1855
- return config?.default_repo_paths ?? [];
1856
- }
1857
- async function runAgentRules(command, io) {
1858
- const hosts = agentRuleHosts(command.host);
1859
- const scopePath = path.resolve(command.repoRoot ?? process.cwd());
1860
- const result = command.action === "install"
1861
- ? await installAgentRules({ homeDir: command.homeDir, hosts, scopePath })
1862
- : command.action === "uninstall"
1863
- ? await uninstallAgentRules({ homeDir: command.homeDir, hosts })
1864
- : await inspectAgentRules({ homeDir: command.homeDir, hosts, scopePath });
1865
- if (command.json) {
1866
- writeLine(io.stdout, JSON.stringify(result, null, 2));
1867
- return 0;
1868
- }
1869
- if ("installed" in result) {
1870
- writeLine(io.stdout, result.installed
1871
- ? "Tower agent rules are installed."
1872
- : "Tower agent rules are not installed.");
1873
- }
1874
- else {
1875
- writeLine(io.stdout, agentRulesStatusLine(result));
1876
- }
1877
- for (const target of result.targets) {
1878
- writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
1879
- }
1880
- return 0;
1881
- }
1882
- function agentRuleHosts(host) {
1883
- return host === "all" ? ["codex", "claude"] : [host];
1884
- }
1885
- function agentRulesStatusLine(result) {
1886
- switch (result.status) {
1887
- case "created":
1888
- return "Tower agent rules installed.";
1889
- case "updated":
1890
- return "Tower agent rules updated.";
1891
- case "unchanged":
1892
- return "Tower agent rules already current.";
1893
- case "missing":
1894
- return "Tower agent rules were not installed.";
1895
- }
1896
- }
1897
- function agentRuleHostLabel(host) {
1898
- return host === "claude" ? "CLAUDE.md" : "AGENTS.md";
1899
- }
1900
- function writeAutostartResult(io, result) {
1901
- switch (result.status) {
1902
- case "installed":
1903
- writeLine(io.stdout, result.loaded
1904
- ? "Tower autostart installed and loaded."
1905
- : "Tower autostart installed (the operating-system scheduler reported a problem).");
1906
- writeLine(io.stdout, `Label: ${result.label}`);
1907
- writeLine(io.stdout, autostartLocationLine(result));
1908
- writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
1909
- writeLine(io.stdout, `Repo: ${result.work_dir}`);
1910
- writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
1911
- if (!result.loaded && result.message)
1912
- writeLine(io.stderr, result.message);
1913
- return;
1914
- case "uninstalled":
1915
- writeLine(io.stdout, "Tower autostart removed.");
1916
- writeLine(io.stdout, autostartLocationLine(result));
1917
- return;
1918
- case "absent":
1919
- writeLine(io.stdout, "Tower autostart is not installed.");
1920
- writeLine(io.stdout, autostartLocationLine(result));
1921
- return;
1922
- case "loaded":
1923
- writeLine(io.stdout, "Tower autostart is installed and loaded.");
1924
- writeLine(io.stdout, autostartLocationLine(result));
1925
- return;
1926
- case "not_loaded":
1927
- writeLine(io.stdout, "Tower autostart exists but is disabled or not loaded; run `cockpit autostart install` to repair it.");
1928
- writeLine(io.stdout, autostartLocationLine(result));
1929
- if (result.message)
1930
- writeLine(io.stderr, result.message);
1931
- return;
1932
- case "unsupported":
1933
- writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
1934
- return;
1935
- }
1936
- }
1937
- function autostartLocationLine(result) {
1938
- return result.task_name
1939
- ? `Task: ${result.task_name}`
1940
- : `Plist: ${result.plist_path}`;
1941
173
  }