@bli-cockpit/cli 0.2.34 → 0.2.36

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.
@@ -411,297 +411,355 @@ async function resolveAndSaveDoctorRoots(command, io) {
411
411
  writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
412
412
  }
413
413
  }
414
+ /**
415
+ * Every onboard exit funnels through here so the best-effort install-event
416
+ * report always fires, pass or blocked — replaces a `finish` closure that used
417
+ * to live inside `runOnboard` so the flow functions below can call it too.
418
+ */
419
+ async function finishOnboardRun(command, installEvents, io, code) {
420
+ await reportInstallEventsBestEffort({
421
+ homeDir: command.homeDir,
422
+ dashboardUrl: command.dashboardUrl,
423
+ command: "onboard",
424
+ events: installEvents,
425
+ json: command.json,
426
+ io,
427
+ });
428
+ return code;
429
+ }
430
+ /**
431
+ * Every onboard `--json` payload is `onboardResult` plus the roots this pass
432
+ * resolved plus whatever extra keys that arm of onboarding adds. Assembling it
433
+ * here means the call sites below cannot drift on key order or forget a field
434
+ * a sibling call site remembers.
435
+ */
436
+ function onboardJsonPayload(resultStatus, command, install, pair, sync, status, rootsResult, extra = {}) {
437
+ return {
438
+ ...onboardResult(resultStatus, command, install, pair, sync, status),
439
+ collection_roots: rootsResult?.roots ?? [],
440
+ root_resolution: rootsResult,
441
+ ...extra,
442
+ };
443
+ }
444
+ function writeOnboardJson(io, payload) {
445
+ writeLine(io.stdout, JSON.stringify(payload, null, 2));
446
+ }
447
+ /** Step 0: which folders are approved, and who claims to own this machine's uploads. */
448
+ async function prepareOnboardRoots(command, installEvents, io) {
449
+ const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
450
+ const rootsResult = resolvedRoots.rootsResult;
451
+ const collectionRoots = rootsResult.roots;
452
+ const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
453
+ const primaryRoot = resolvedRoots.primaryRoot;
454
+ const resolvedCommand = {
455
+ ...command,
456
+ repoRoot: primaryRoot,
457
+ collectionRoots,
458
+ };
459
+ if (!command.json) {
460
+ writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
461
+ }
462
+ if (rootsResult.homeRootOptIn) {
463
+ addInstallEvent(installEvents, "home_root_optin", "ok");
464
+ }
465
+ return {
466
+ resolvedRoots,
467
+ rootsResult,
468
+ collectionRoots,
469
+ attributionCollectionRoots,
470
+ primaryRoot,
471
+ existingConfig: resolvedRoots.existingConfig,
472
+ resolvedCommand,
473
+ };
474
+ }
475
+ /** Step 1: write the local config that makes this machine a known collector. */
476
+ async function installOnboardConfig(command, resolvedRoots, installEvents, io) {
477
+ const install = await persistOnboardingRootConfig(command, resolvedRoots);
478
+ addInstallEvent(installEvents, "install", "ok");
479
+ if (!command.json) {
480
+ writeLine(io.stdout, "1/5 Installed local collector.");
481
+ writeLine(io.stdout, `Config: ${install.paths.config_file}`);
482
+ }
483
+ return install;
484
+ }
485
+ /**
486
+ * The onboarding runbook: resolve roots, install, pair, discover worktrees,
487
+ * then hand off to whichever arm matches what was found — one repo runs the
488
+ * single-repo sync-then-backfill sequence (`runOnboardSingleRepoFlow`),
489
+ * several run the parent-folder one (`runOnboardMultiRepoFlow`). Any failure
490
+ * anywhere in either arm unwinds to the single catch below.
491
+ */
414
492
  async function runOnboard(command, io) {
415
493
  const installEvents = [];
416
- const finish = async (code) => {
417
- await reportInstallEventsBestEffort({
418
- homeDir: command.homeDir,
419
- dashboardUrl: command.dashboardUrl,
420
- command: "onboard",
421
- events: installEvents,
422
- json: command.json,
423
- io,
424
- });
425
- return code;
426
- };
427
494
  let install = null;
428
495
  let pair = null;
429
- let sync = null;
430
- let status = null;
431
496
  let rootsResult = null;
432
- let agentRules = null;
433
- let autostart = null;
497
+ // `sync` has to survive a throw from inside either flow function, so it is
498
+ // mutated through this holder rather than returned — a return only happens
499
+ // on the success path, and the failure path needs the value too.
500
+ const syncHolder = {
501
+ current: null,
502
+ };
434
503
  try {
435
504
  if (!command.json)
436
505
  writeOnboardBanner(command, io);
437
- const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
438
- const existingConfig = resolvedRoots.existingConfig;
439
- rootsResult = resolvedRoots.rootsResult;
440
- const collectionRoots = rootsResult.roots;
441
- const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
442
- const primaryRoot = resolvedRoots.primaryRoot;
443
- const resolvedCommand = {
444
- ...command,
445
- repoRoot: primaryRoot,
446
- collectionRoots,
447
- };
448
- if (!command.json) {
449
- writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
450
- }
451
- if (rootsResult.homeRootOptIn) {
452
- addInstallEvent(installEvents, "home_root_optin", "ok");
453
- }
454
- const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
455
- install = await persistOnboardingRootConfig(command, resolvedRoots);
456
- addInstallEvent(installEvents, "install", "ok");
457
- if (!command.json) {
458
- writeLine(io.stdout, "1/5 Installed local collector.");
459
- writeLine(io.stdout, `Config: ${install.paths.config_file}`);
460
- }
461
- pair = await pairForOnboarding(command, { primaryRoot, claimedOwnerEmail }, installEvents, io);
462
- const worktrees = await discoverCommandWorktrees(collectionRoots, {
506
+ const roots = await prepareOnboardRoots(command, installEvents, io);
507
+ rootsResult = roots.rootsResult;
508
+ const claimedOwnerEmail = await resolveOnboardEmail(roots.resolvedCommand, roots.collectionRoots, roots.existingConfig, io);
509
+ install = await installOnboardConfig(command, roots.resolvedRoots, installEvents, io);
510
+ pair = await pairForOnboarding(command, { primaryRoot: roots.primaryRoot, claimedOwnerEmail }, installEvents, io);
511
+ const worktrees = await discoverCommandWorktrees(roots.collectionRoots, {
463
512
  maxDepth: command.maxDepth,
464
513
  maxRepos: command.maxRepos,
465
514
  homeDir: command.homeDir,
466
515
  allowEmpty: true,
467
516
  }, io);
468
- if (worktrees.length > 1) {
469
- const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
470
- addInstallEvent(installEvents, "work_context", "ok");
471
- addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
472
- if (!multi.ok) {
473
- if (command.json) {
474
- writeLine(io.stdout, JSON.stringify({
475
- ...onboardResult("blocked", resolvedCommand, install, pair, null, null),
476
- collection_roots: collectionRoots,
477
- root_resolution: rootsResult,
478
- mode: "multi_repo",
479
- repos: multi.results,
480
- codex_sessions: multi.codex_sessions,
481
- blocker: "sync_blocked",
482
- next_step: "Run `cockpit sync --json` until every root returns an uploaded receipt.",
483
- }, null, 2));
484
- }
485
- return finish(1);
486
- }
487
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
488
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
489
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
490
- ? undefined
491
- : backfill.failureReason ?? "backfill_incomplete");
492
- if (!completedBackfill) {
493
- writeOnboardBackfillBlockedResult(io, {
494
- json: command.json,
495
- base: {
496
- ...onboardResult("blocked", resolvedCommand, install, pair, null, null),
497
- collection_roots: collectionRoots,
498
- root_resolution: rootsResult,
499
- },
500
- extra: {
501
- mode: "multi_repo",
502
- repos: multi.results,
503
- codex_sessions: multi.codex_sessions,
504
- },
505
- backfill,
506
- });
507
- return finish(1);
508
- }
509
- const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
510
- agentRules = multiSetup.agentRules;
511
- autostart = multiSetup.autostart;
512
- const onboardOk = multiSetup.ok;
513
- if (command.json) {
514
- writeLine(io.stdout, JSON.stringify({
515
- ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
516
- collection_roots: collectionRoots,
517
- root_resolution: rootsResult,
518
- agent_rules: agentRules,
519
- autostart,
520
- backfill: completedBackfill,
521
- mode: "multi_repo",
522
- repos: multi.results,
523
- codex_sessions: multi.codex_sessions,
524
- ...(!onboardOk && multi.ok
525
- ? {
526
- blocker: "autostart_load_failed",
527
- next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
528
- }
529
- : {}),
530
- }, null, 2));
531
- }
532
- if (onboardOk && !command.json) {
533
- writeLine(io.stdout, "PASS: Tower is set up and collecting.");
534
- writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
535
- pair,
536
- status,
537
- sync: null,
538
- agentRules,
539
- autostart,
540
- initialSyncOk: true,
541
- backfill: completedBackfill,
542
- });
543
- }
544
- else if (multi.ok && !command.json) {
545
- writeOnboardAutostartBlocker(io, autostart);
546
- }
547
- return finish(onboardOk ? 0 : 1);
548
- }
549
- const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
550
- const context = await startLocalWorkContext({
551
- homeDir: command.homeDir,
552
- repoRoot: worktreeRoot,
553
- branch: command.branch,
554
- activeTicketId: command.activeTicketId,
555
- });
556
- if (!command.json) {
557
- writeLine(io.stdout, "3/5 Work context active.");
558
- writeLine(io.stdout, `Repo: ${context.repo}`);
559
- writeLine(io.stdout, `Branch: ${context.branch}`);
560
- writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
561
- writeLine(io.stdout, `Context: ${context.work_context_id}`);
562
- }
563
- addInstallEvent(installEvents, "work_context", "ok");
564
- const run = await runAttributedWorktreeSync({
565
- homeDir: command.homeDir,
566
- dashboardUrl: command.dashboardUrl,
567
- collectionRoots: attributionCollectionRoots,
568
- startContexts: false,
517
+ const ctx = {
518
+ command,
519
+ resolvedCommand: roots.resolvedCommand,
520
+ collectionRoots: roots.collectionRoots,
521
+ rootsResult: roots.rootsResult,
522
+ install,
523
+ pair,
524
+ installEvents,
525
+ io,
569
526
  worktrees,
570
- fetchImpl: io.fetch,
571
- });
572
- sync = run.outcomes[0]?.sync ?? null;
573
- if (!sync) {
574
- throw new Error("Onboard sync produced no result for the repo worktree.");
527
+ };
528
+ if (worktrees.length > 1) {
529
+ return await runOnboardMultiRepoFlow(ctx);
575
530
  }
576
- status = await inspectLocalCollectorStatus({
577
- homeDir: command.homeDir,
578
- repoRoot: worktreeRoot,
579
- branch: command.branch,
531
+ return await runOnboardSingleRepoFlow(ctx, {
532
+ attributionCollectionRoots: roots.attributionCollectionRoots,
533
+ primaryRoot: roots.primaryRoot,
534
+ syncHolder,
580
535
  });
581
- if (!run.ok || sync.status !== "uploaded") {
582
- addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
583
- const collectionRunStatus = attributedSyncRunStatus(run);
584
- if (command.json) {
585
- writeLine(io.stdout, JSON.stringify({
586
- ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
587
- collection_roots: collectionRoots,
588
- root_resolution: rootsResult,
589
- codex_sessions: run.summary,
590
- collection_status: collectionRunStatus,
591
- }, null, 2));
592
- }
593
- else {
594
- const syncFailureReason = sync.status === "spooled" ? sync.failure_reason : null;
595
- const retryCommand = sync.status === "spooled"
596
- ? sync.retry_command
597
- : `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
598
- writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
599
- writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? collectionRunStatus : run.summary.report_reason)}`);
600
- writeAgentSessionSummary(io, run.summary);
601
- writeLine(io.stderr, `Retry: ${retryCommand}`);
602
- }
603
- return finish(1);
604
- }
605
- addInstallEvent(installEvents, "sync", "ok");
606
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
607
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
608
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
609
- ? undefined
610
- : backfill.failureReason ?? "backfill_incomplete");
611
- if (!completedBackfill) {
612
- writeOnboardBackfillBlockedResult(io, {
613
- json: command.json,
614
- base: {
615
- ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
616
- collection_roots: collectionRoots,
617
- root_resolution: rootsResult,
618
- },
619
- extra: { codex_sessions: run.summary },
620
- backfill,
621
- });
622
- return finish(1);
623
- }
536
+ }
537
+ catch (error) {
538
+ return await handleOnboardFailure(error, command, rootsResult, install, pair, syncHolder.current, installEvents, io);
539
+ }
540
+ }
541
+ /** The parent-folder arm: sync every discovered worktree, then backfill and finish setup once. */
542
+ async function runOnboardMultiRepoFlow(ctx) {
543
+ const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
544
+ const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
545
+ addInstallEvent(installEvents, "work_context", "ok");
546
+ addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
547
+ if (!multi.ok) {
624
548
  if (command.json) {
625
- const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
626
- agentRules = jsonSetup.agentRules;
627
- autostart = jsonSetup.autostart;
628
- const onboardOk = jsonSetup.ok;
629
- writeLine(io.stdout, JSON.stringify({
630
- ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status),
631
- collection_roots: collectionRoots,
632
- root_resolution: rootsResult,
633
- agent_rules: agentRules,
634
- autostart,
635
- backfill: completedBackfill,
636
- codex_sessions: run.summary,
637
- ...(!onboardOk
638
- ? {
639
- blocker: "autostart_load_failed",
640
- next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
641
- }
642
- : {}),
643
- }, null, 2));
644
- return finish(onboardOk ? 0 : 1);
645
- }
646
- writeLine(io.stdout, "4/5 Uploaded what you worked on.");
647
- writeLine(io.stdout, `HTTP: ${sync.http_status}`);
648
- writeLine(io.stdout, `Things recorded: ${sync.event_count}`);
649
- writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
650
- writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
651
- writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
652
- writeLine(io.stdout, rawEvidenceSyncLine(sync));
653
- writeAgentSessionSummary(io, run.summary);
654
- writeLine(io.stdout, "5/5 Status ready.");
655
- writeLine(io.stdout, `Upload state: ${status.upload_state}`);
656
- writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
657
- const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
658
- agentRules = setup.agentRules;
659
- autostart = setup.autostart;
660
- if (!setup.ok) {
661
- writeOnboardAutostartBlocker(io, autostart);
662
- return finish(1);
549
+ writeOnboardJson(io, onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult, {
550
+ mode: "multi_repo",
551
+ repos: multi.results,
552
+ codex_sessions: multi.codex_sessions,
553
+ blocker: "sync_blocked",
554
+ next_step: "Run `cockpit sync --json` until every root returns an uploaded receipt.",
555
+ }));
663
556
  }
557
+ return finishOnboardRun(command, installEvents, io, 1);
558
+ }
559
+ const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
560
+ const completedBackfill = backfill.status === "complete" ? backfill.result : null;
561
+ addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
562
+ ? undefined
563
+ : backfill.failureReason ?? "backfill_incomplete");
564
+ if (!completedBackfill) {
565
+ writeOnboardBackfillBlockedResult(io, {
566
+ json: command.json,
567
+ base: onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult),
568
+ extra: {
569
+ mode: "multi_repo",
570
+ repos: multi.results,
571
+ codex_sessions: multi.codex_sessions,
572
+ },
573
+ backfill,
574
+ });
575
+ return finishOnboardRun(command, installEvents, io, 1);
576
+ }
577
+ const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
578
+ const { agentRules, autostart } = multiSetup;
579
+ const onboardOk = multiSetup.ok;
580
+ if (command.json) {
581
+ writeOnboardJson(io, onboardJsonPayload(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null, rootsResult, {
582
+ agent_rules: agentRules,
583
+ autostart,
584
+ backfill: completedBackfill,
585
+ mode: "multi_repo",
586
+ repos: multi.results,
587
+ codex_sessions: multi.codex_sessions,
588
+ ...(!onboardOk && multi.ok
589
+ ? {
590
+ blocker: "autostart_load_failed",
591
+ next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
592
+ }
593
+ : {}),
594
+ }));
595
+ }
596
+ if (onboardOk && !command.json) {
664
597
  writeLine(io.stdout, "PASS: Tower is set up and collecting.");
665
598
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
666
599
  pair,
667
- status,
668
- sync,
600
+ status: null,
601
+ sync: null,
669
602
  agentRules,
670
603
  autostart,
671
604
  initialSyncOk: true,
672
605
  backfill: completedBackfill,
673
606
  });
674
- return finish(0);
675
607
  }
676
- catch (error) {
677
- const message = errorMessage(error);
678
- const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
679
- status = statusRoot
680
- ? await inspectLocalCollectorStatus({
681
- homeDir: command.homeDir,
682
- repoRoot: statusRoot,
683
- branch: command.branch,
684
- }).catch(() => null)
685
- : null;
686
- const blocker = classifyOnboardBlocker(message);
687
- addOnboardFailureEvent(installEvents, blocker);
688
- const nextStep = nextStepForOnboardBlocker(blocker, command);
608
+ else if (multi.ok && !command.json) {
609
+ writeOnboardAutostartBlocker(io, autostart);
610
+ }
611
+ return finishOnboardRun(command, installEvents, io, onboardOk ? 0 : 1);
612
+ }
613
+ /** The single-repo arm: start the work context, sync it, backfill, then finish setup once. */
614
+ async function runOnboardSingleRepoFlow(ctx, single) {
615
+ const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
616
+ const worktreeRoot = worktrees[0]?.repo_root ?? single.primaryRoot;
617
+ const context = await startLocalWorkContext({
618
+ homeDir: command.homeDir,
619
+ repoRoot: worktreeRoot,
620
+ branch: command.branch,
621
+ activeTicketId: command.activeTicketId,
622
+ });
623
+ if (!command.json) {
624
+ writeLine(io.stdout, "3/5 Work context active.");
625
+ writeLine(io.stdout, `Repo: ${context.repo}`);
626
+ writeLine(io.stdout, `Branch: ${context.branch}`);
627
+ writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
628
+ writeLine(io.stdout, `Context: ${context.work_context_id}`);
629
+ }
630
+ addInstallEvent(installEvents, "work_context", "ok");
631
+ const run = await runAttributedWorktreeSync({
632
+ homeDir: command.homeDir,
633
+ dashboardUrl: command.dashboardUrl,
634
+ collectionRoots: single.attributionCollectionRoots,
635
+ startContexts: false,
636
+ worktrees,
637
+ fetchImpl: io.fetch,
638
+ });
639
+ const sync = run.outcomes[0]?.sync ?? null;
640
+ single.syncHolder.current = sync;
641
+ if (!sync) {
642
+ throw new Error("Onboard sync produced no result for the repo worktree.");
643
+ }
644
+ const status = await inspectLocalCollectorStatus({
645
+ homeDir: command.homeDir,
646
+ repoRoot: worktreeRoot,
647
+ branch: command.branch,
648
+ });
649
+ if (!run.ok || sync.status !== "uploaded") {
650
+ addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
651
+ const collectionRunStatus = attributedSyncRunStatus(run);
689
652
  if (command.json) {
690
- writeLine(io.stdout, JSON.stringify({
691
- ...onboardResult("blocked", command, install, pair, sync, status),
692
- collection_roots: rootsResult?.roots ?? [],
693
- root_resolution: rootsResult,
694
- blocker,
695
- message,
696
- next_step: nextStep,
697
- }, null, 2));
653
+ writeOnboardJson(io, onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult, {
654
+ codex_sessions: run.summary,
655
+ collection_status: collectionRunStatus,
656
+ }));
698
657
  }
699
658
  else {
700
- writeLine(io.stderr, `BLOCKED: ${message}`);
701
- writeLine(io.stderr, `Next: ${nextStep}`);
659
+ const syncFailureReason = sync.status === "spooled" ? sync.failure_reason : null;
660
+ const retryCommand = sync.status === "spooled"
661
+ ? sync.retry_command
662
+ : `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
663
+ writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
664
+ writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? collectionRunStatus : run.summary.report_reason)}`);
665
+ writeAgentSessionSummary(io, run.summary);
666
+ writeLine(io.stderr, `Retry: ${retryCommand}`);
702
667
  }
703
- return finish(1);
668
+ return finishOnboardRun(command, installEvents, io, 1);
669
+ }
670
+ addInstallEvent(installEvents, "sync", "ok");
671
+ const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
672
+ const completedBackfill = backfill.status === "complete" ? backfill.result : null;
673
+ addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
674
+ ? undefined
675
+ : backfill.failureReason ?? "backfill_incomplete");
676
+ if (!completedBackfill) {
677
+ writeOnboardBackfillBlockedResult(io, {
678
+ json: command.json,
679
+ base: onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult),
680
+ extra: { codex_sessions: run.summary },
681
+ backfill,
682
+ });
683
+ return finishOnboardRun(command, installEvents, io, 1);
684
+ }
685
+ if (command.json) {
686
+ const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
687
+ const { agentRules, autostart } = jsonSetup;
688
+ const onboardOk = jsonSetup.ok;
689
+ writeOnboardJson(io, onboardJsonPayload(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status, rootsResult, {
690
+ agent_rules: agentRules,
691
+ autostart,
692
+ backfill: completedBackfill,
693
+ codex_sessions: run.summary,
694
+ ...(!onboardOk
695
+ ? {
696
+ blocker: "autostart_load_failed",
697
+ next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
698
+ }
699
+ : {}),
700
+ }));
701
+ return finishOnboardRun(command, installEvents, io, onboardOk ? 0 : 1);
702
+ }
703
+ writeLine(io.stdout, "4/5 Uploaded what you worked on.");
704
+ writeLine(io.stdout, `HTTP: ${sync.http_status}`);
705
+ writeLine(io.stdout, `Things recorded: ${sync.event_count}`);
706
+ writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
707
+ writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
708
+ writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
709
+ writeLine(io.stdout, rawEvidenceSyncLine(sync));
710
+ writeAgentSessionSummary(io, run.summary);
711
+ writeLine(io.stdout, "5/5 Status ready.");
712
+ writeLine(io.stdout, `Upload state: ${status.upload_state}`);
713
+ writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
714
+ const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
715
+ const { agentRules, autostart } = setup;
716
+ if (!setup.ok) {
717
+ writeOnboardAutostartBlocker(io, autostart);
718
+ return finishOnboardRun(command, installEvents, io, 1);
719
+ }
720
+ writeLine(io.stdout, "PASS: Tower is set up and collecting.");
721
+ writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
722
+ pair,
723
+ status,
724
+ sync,
725
+ agentRules,
726
+ autostart,
727
+ initialSyncOk: true,
728
+ backfill: completedBackfill,
729
+ });
730
+ return finishOnboardRun(command, installEvents, io, 0);
731
+ }
732
+ /**
733
+ * Every uncaught error from either onboarding arm lands here. `status` is
734
+ * always recomputed fresh against whichever root got furthest, never trusted
735
+ * from before the throw — the arms already recompute it themselves on their
736
+ * own success paths, so nothing upstream is lost by not threading it through.
737
+ */
738
+ async function handleOnboardFailure(error, command, rootsResult, install, pair, sync, installEvents, io) {
739
+ const message = errorMessage(error);
740
+ const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
741
+ const status = statusRoot
742
+ ? await inspectLocalCollectorStatus({
743
+ homeDir: command.homeDir,
744
+ repoRoot: statusRoot,
745
+ branch: command.branch,
746
+ }).catch(() => null)
747
+ : null;
748
+ const blocker = classifyOnboardBlocker(message);
749
+ addOnboardFailureEvent(installEvents, blocker);
750
+ const nextStep = nextStepForOnboardBlocker(blocker, command);
751
+ if (command.json) {
752
+ writeOnboardJson(io, onboardJsonPayload("blocked", command, install, pair, sync, status, rootsResult, {
753
+ blocker,
754
+ message,
755
+ next_step: nextStep,
756
+ }));
757
+ }
758
+ else {
759
+ writeLine(io.stderr, `BLOCKED: ${message}`);
760
+ writeLine(io.stderr, `Next: ${nextStep}`);
704
761
  }
762
+ return finishOnboardRun(command, installEvents, io, 1);
705
763
  }
706
764
  async function runMultiRepoOnboard(command, io, worktrees) {
707
765
  if (!command.json) {
@@ -1207,6 +1265,13 @@ function syncResult(run) {
1207
1265
  failureReasons: run.ok ? [] : run.failure_reasons,
1208
1266
  };
1209
1267
  }
1268
+ /**
1269
+ * The sync runbook: resolve which roots to collect, dedup staged packs before
1270
+ * touching anything else, discover worktrees, sync them, then report — one of
1271
+ * three shapes depending on how many worktrees came back. The three shapes
1272
+ * share nothing but `dedup`, so each gets its own step function below rather
1273
+ * than one branchy body.
1274
+ */
1210
1275
  async function runSyncLocked(command, io) {
1211
1276
  const collectionRoots = await resolveSyncCollectionRoots(command);
1212
1277
  // Before anything is collected: collapse byte-identical staged packs. It runs
@@ -1233,33 +1298,7 @@ async function runSyncLocked(command, io) {
1233
1298
  fetchImpl: io.fetch,
1234
1299
  });
1235
1300
  if (run.outcomes.length > 1) {
1236
- const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1237
- const collectionRunStatus = attributedSyncRunStatus(run);
1238
- const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1239
- if (command.json) {
1240
- writeLine(io.stdout, JSON.stringify({
1241
- mode: "multi_repo",
1242
- status: collectionRunStatus,
1243
- collection_complete: run.ok,
1244
- results: run.outcomes.map((outcome) => outcome.sync),
1245
- repos: rows,
1246
- codex_sessions: run.summary,
1247
- raw_evidence_gc: gc,
1248
- raw_evidence_dedup: dedup,
1249
- }, null, 2));
1250
- return syncResult(run);
1251
- }
1252
- 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).`);
1253
- for (const outcome of run.outcomes) {
1254
- const { worktree, sync } = outcome;
1255
- const uploaded = sync.status === "uploaded";
1256
- const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1257
- 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}`);
1258
- }
1259
- writeAgentSessionSummary(io, run.summary);
1260
- if (gc && !gc.skipped)
1261
- writeLine(io.stdout, rawEvidenceGcSummary(gc));
1262
- return syncResult(run);
1301
+ return reportMultiRepoSync(command, io, run, dedup);
1263
1302
  }
1264
1303
  // Zero worktrees is a legitimate steady state, not a failure: an approved
1265
1304
  // root can hold no git repos, and sessions upload independently of
@@ -1268,25 +1307,60 @@ async function runSyncLocked(command, io) {
1268
1307
  // fleet machine with a single empty root and taught people to ignore
1269
1308
  // sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
1270
1309
  if (run.outcomes.length === 0) {
1271
- const collectionRunStatus = attributedSyncRunStatus(run);
1272
- const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1273
- if (command.json) {
1274
- writeLine(io.stdout, JSON.stringify({
1275
- mode: "no_worktrees",
1276
- status: collectionRunStatus,
1277
- collection_complete: run.ok,
1278
- codex_sessions: run.summary,
1279
- raw_evidence_gc: gc,
1280
- raw_evidence_dedup: dedup,
1281
- }, null, 2));
1282
- return syncResult(run);
1283
- }
1284
- writeLine(run.ok ? io.stdout : io.stderr, `Tower sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
1285
- writeAgentSessionSummary(io, run.summary);
1286
- if (gc && !gc.skipped)
1287
- writeLine(io.stdout, rawEvidenceGcSummary(gc));
1310
+ return reportNoWorktreeSync(command, io, run, dedup);
1311
+ }
1312
+ return reportSingleRepoSync(command, io, run, dedup);
1313
+ }
1314
+ async function reportMultiRepoSync(command, io, run, dedup) {
1315
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1316
+ const collectionRunStatus = attributedSyncRunStatus(run);
1317
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1318
+ if (command.json) {
1319
+ writeLine(io.stdout, JSON.stringify({
1320
+ mode: "multi_repo",
1321
+ status: collectionRunStatus,
1322
+ collection_complete: run.ok,
1323
+ results: run.outcomes.map((outcome) => outcome.sync),
1324
+ repos: rows,
1325
+ codex_sessions: run.summary,
1326
+ raw_evidence_gc: gc,
1327
+ raw_evidence_dedup: dedup,
1328
+ }, null, 2));
1288
1329
  return syncResult(run);
1289
1330
  }
1331
+ 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).`);
1332
+ for (const outcome of run.outcomes) {
1333
+ const { worktree, sync } = outcome;
1334
+ const uploaded = sync.status === "uploaded";
1335
+ const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1336
+ 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}`);
1337
+ }
1338
+ writeAgentSessionSummary(io, run.summary);
1339
+ if (gc && !gc.skipped)
1340
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1341
+ return syncResult(run);
1342
+ }
1343
+ async function reportNoWorktreeSync(command, io, run, dedup) {
1344
+ const collectionRunStatus = attributedSyncRunStatus(run);
1345
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1346
+ if (command.json) {
1347
+ writeLine(io.stdout, JSON.stringify({
1348
+ mode: "no_worktrees",
1349
+ status: collectionRunStatus,
1350
+ collection_complete: run.ok,
1351
+ codex_sessions: run.summary,
1352
+ raw_evidence_gc: gc,
1353
+ raw_evidence_dedup: dedup,
1354
+ }, null, 2));
1355
+ return syncResult(run);
1356
+ }
1357
+ writeLine(run.ok ? io.stdout : io.stderr, `Tower sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
1358
+ writeAgentSessionSummary(io, run.summary);
1359
+ if (gc && !gc.skipped)
1360
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1361
+ return syncResult(run);
1362
+ }
1363
+ async function reportSingleRepoSync(command, io, run, dedup) {
1290
1364
  const result = run.outcomes[0]?.sync;
1291
1365
  if (!result) {
1292
1366
  throw new Error("Sync produced no result for the repo worktree.");