From a27c2b338799f72d7d8d041da57a2c8a3af4a92c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:17:50 -0700 Subject: [PATCH] perf: checkpoint startup boundaries and bundle diagnostics --- .github/workflows/pr.yml | 3 + .../startup-diagnostics-bootstrap.ts | 158 +++ config/electron-bundle-budgets.json | 30 + .../scripts/electron-bundle-entry-report.mjs | 223 +++++ .../electron-bundle-entry-report.test.mjs | 177 ++++ .../scripts/pr-workflow-parallelism.test.mjs | 14 + .../startup-diagnostics-bootstrap.test.ts | 111 +++ ...formance-architecture-review-2026-07-29.md | 766 +++++++++++++++ electron.vite.config.ts | 132 +-- .../app/h/[hostId]/session/[worktreeId].tsx | 6 +- mobile/package.json | 1 + .../expo-process-memory/android/build.gradle | 15 + .../android/src/main/AndroidManifest.xml | 2 + .../processmemory/ExpoProcessMemoryModule.kt | 27 + .../expo-module.config.json | 9 + .../ios/ExpoProcessMemory.podspec | 19 + .../ios/ExpoProcessMemoryModule.swift | 32 + .../packages/expo-process-memory/package.json | 17 + .../src/ExpoProcessMemoryModule.ts | 8 + .../packages/expo-process-memory/src/index.ts | 2 + .../src/process-memory-measurement.ts | 8 + .../expo-process-memory/tsconfig.json | 9 + mobile/pnpm-lock.yaml | 16 + .../mobile-process-memory-diagnostics.test.ts | 121 +++ .../mobile-process-memory-diagnostics.ts | 107 ++ mobile/src/session/TerminalPaneView.tsx | 37 +- .../mobile-terminal-diagnostics.test.ts | 74 ++ .../session/mobile-terminal-diagnostics.ts | 47 +- ...ile-terminal-process-memory-diagnostics.ts | 41 + ...terminal-webview-count-diagnostics.test.ts | 149 +++ ...bile-terminal-webview-count-diagnostics.ts | 126 +++ package.json | 1 + .../browser-kernel-window-dependencies.ts | 21 + .../claude-usage-file-path.test.ts | 35 + .../claude-usage/claude-usage-file-path.ts | 16 + src/main/claude-usage/store.ts | 25 +- .../codex-usage/codex-usage-file-path.test.ts | 35 + src/main/codex-usage/codex-usage-file-path.ts | 16 + src/main/codex-usage/store.ts | 23 +- .../main-thread-churn-probe-report.test.ts | 73 ++ .../main-thread-churn-probe.test.ts | 31 + .../diagnostics/main-thread-churn-probe.ts | 45 +- src/main/index.ts | 929 ++++++++++-------- src/main/ipc/cli.test.ts | 4 +- src/main/ipc/cli.ts | 2 +- ...me-environment-request-connections.test.ts | 102 ++ ...runtime-environment-request-connections.ts | 37 +- ...time-environment-shared-control-support.ts | 4 +- .../runtime-environment-transport-routing.ts | 21 +- src/main/ipc/runtime-environments.test.ts | 2 + src/main/macos-tcc-prompt-notice.test.ts | 4 +- src/main/macos-tcc-prompt-notice.ts | 2 +- .../opencode-usage-file-path.test.ts | 36 + .../opencode-usage-file-path.ts | 16 + src/main/opencode-usage/store.ts | 23 +- src/main/persistence-data-path.test.ts | 93 ++ src/main/persistence-data-path.ts | 52 + src/main/persistence.ts | 74 +- .../providers/ssh-filesystem-file-upload.ts | 5 +- ...esystem-provider-download-boundary.test.ts | 73 ++ .../providers/ssh-filesystem-provider-sftp.ts | 29 - ...ilesystem-provider-upload-boundary.test.ts | 101 ++ src/main/providers/ssh-filesystem-provider.ts | 21 +- .../providers/ssh-filesystem-sftp-stat.ts | 30 + src/main/serve-update-handoff.test.ts | 4 +- src/main/serve-update-handoff.ts | 2 +- src/main/ssh/sftp-upload-capability.ts | 19 + ...plexer-synthetic-latency.benchmark.test.ts | 119 +++ src/main/ssh/ssh-channel-multiplexer.ts | 2 + src/main/ssh/ssh-connection.ts | 9 +- src/main/ssh/ssh-relay-deploy-helpers.ts | 23 +- .../ssh/ssh-relay-install-transfers.test.ts | 122 +++ src/main/ssh/ssh-relay-install-transfers.ts | 3 +- .../ssh-remote-cli-host-passthrough.test.ts | 2 +- .../ssh/ssh-remote-cli-host-passthrough.ts | 2 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 2 +- ...time-coordination-startup-boundary.test.ts | 152 +++ ...me-coordination-startup-capability.test.ts | 41 + ...runtime-coordination-startup-capability.ts | 34 + ...runtime-coordination-startup-owner.test.ts | 37 + ...ount-runtime-coordination-startup-owner.ts | 16 + .../account-services-startup-boundary.test.ts | 148 +++ ...ccount-services-startup-capability.test.ts | 129 +++ .../account-services-startup-capability.ts | 51 + .../agent-awake-startup-boundary.test.ts | 45 + .../agent-awake-startup-capability.test.ts | 29 + .../startup/agent-awake-startup-capability.ts | 8 + .../agent-browser-startup-boundary.test.ts | 33 + .../agent-browser-startup-capability.test.ts | 33 + .../agent-browser-startup-capability.ts | 19 + ...gent-hook-runtime-startup-boundary.test.ts | 126 +++ ...nt-hook-runtime-startup-capability.test.ts | 53 + .../agent-hook-runtime-startup-capability.ts | 45 + .../agent-hook-runtime-startup-owner.test.ts | 45 + .../agent-hook-runtime-startup-owner.ts | 20 + ...utomation-service-startup-boundary.test.ts | 70 ++ ...omation-service-startup-capability.test.ts | 38 + .../automation-service-startup-capability.ts | 10 + .../browser-kernel-startup-boundary.test.ts | 145 +++ .../browser-kernel-startup-capability.test.ts | 60 ++ .../browser-kernel-startup-capability.ts | 16 + ...laude-usage-store-startup-boundary.test.ts | 79 ++ ...ude-usage-store-startup-capability.test.ts | 32 + .../claude-usage-store-startup-capability.ts | 9 + .../startup/cli-wsl-startup-boundary.test.ts | 148 +++ .../cli-wsl-startup-capability.test.ts | 76 ++ .../startup/cli-wsl-startup-capability.ts | 13 + ...ex-launch-session-startup-boundary.test.ts | 126 +++ ...-launch-session-startup-capability.test.ts | 67 ++ ...codex-launch-session-startup-capability.ts | 53 + ...codex-launch-session-startup-owner.test.ts | 22 + .../codex-launch-session-startup-owner.ts | 16 + ...codex-usage-store-startup-boundary.test.ts | 81 ++ ...dex-usage-store-startup-capability.test.ts | 32 + .../codex-usage-store-startup-capability.ts | 9 + ...core-ipc-registry-startup-boundary.test.ts | 170 ++++ ...re-ipc-registry-startup-capability.test.ts | 23 + .../core-ipc-registry-startup-capability.ts | 7 + ...rash-hang-runtime-startup-boundary.test.ts | 117 +++ ...sh-hang-runtime-startup-capability.test.ts | 49 + .../crash-hang-runtime-startup-capability.ts | 45 + .../crash-hang-runtime-startup-owner.test.ts | 44 + .../crash-hang-runtime-startup-owner.ts | 20 + ...top-relay-service-startup-boundary.test.ts | 86 ++ ...p-relay-service-startup-capability.test.ts | 32 + ...esktop-relay-service-startup-capability.ts | 9 + .../desktop-shell-startup-boundary.test.ts | 128 +++ .../desktop-shell-startup-capability.test.ts | 51 + .../desktop-shell-startup-capability.ts | 50 + .../desktop-shell-startup-owner.test.ts | 44 + .../startup/desktop-shell-startup-owner.ts | 20 + .../startup/desktop-startup-ordering.test.ts | 2 +- .../startup/emulator-startup-boundary.test.ts | 33 + .../emulator-startup-capability.test.ts | 14 + .../startup/emulator-startup-capability.ts | 10 + ...eybinding-service-startup-boundary.test.ts | 66 ++ ...binding-service-startup-capability.test.ts | 40 + .../keybinding-service-startup-capability.ts | 7 + .../main-window-startup-boundary.test.ts | 148 +++ .../main-window-startup-capability.test.ts | 33 + .../startup/main-window-startup-capability.ts | 21 + .../startup/main-window-startup-owner.test.ts | 30 + src/main/startup/main-window-startup-owner.ts | 16 + ...offscreen-browser-startup-boundary.test.ts | 74 ++ ...fscreen-browser-startup-capability.test.ts | 33 + .../offscreen-browser-startup-capability.ts | 14 + ...ncode-usage-store-startup-boundary.test.ts | 67 ++ ...ode-usage-store-startup-capability.test.ts | 32 + ...opencode-usage-store-startup-capability.ts | 9 + .../plugin-system-startup-boundary.test.ts | 160 +++ .../plugin-system-startup-capability.test.ts | 177 ++++ .../plugin-system-startup-capability.ts | 89 ++ ...time-connectivity-startup-boundary.test.ts | 123 +++ ...me-connectivity-startup-capability.test.ts | 41 + ...runtime-connectivity-startup-capability.ts | 29 + ...runtime-connectivity-startup-owner.test.ts | 34 + .../runtime-connectivity-startup-owner.ts | 16 + ...untime-rpc-server-startup-boundary.test.ts | 148 +++ ...time-rpc-server-startup-capability.test.ts | 39 + .../runtime-rpc-server-startup-capability.ts | 9 + .../runtime-service-startup-boundary.test.ts | 136 +++ ...runtime-service-startup-capability.test.ts | 53 + .../runtime-service-startup-capability.ts | 9 + .../serve-desktop-activation-wiring.test.ts | 4 +- ...-runtime-metadata-startup-boundary.test.ts | 130 +++ ...untime-metadata-startup-capability.test.ts | 33 + ...her-runtime-metadata-startup-capability.ts | 23 + ...her-runtime-metadata-startup-owner.test.ts | 43 + ...-watcher-runtime-metadata-startup-owner.ts | 20 + .../startup/star-nag-startup-boundary.test.ts | 54 + .../star-nag-startup-capability.test.ts | 48 + .../startup/star-nag-startup-capability.ts | 13 + src/main/startup/startup-diagnostics.ts | 9 +- .../stats-collector-startup-boundary.test.ts | 64 ++ ...stats-collector-startup-capability.test.ts | 29 + .../stats-collector-startup-capability.ts | 5 + .../startup/store-startup-boundary.test.ts | 138 +++ .../startup/store-startup-capability.test.ts | 32 + src/main/startup/store-startup-capability.ts | 7 + ...try-observability-startup-boundary.test.ts | 140 +++ ...y-observability-startup-capability.test.ts | 45 + ...emetry-observability-startup-capability.ts | 34 + ...emetry-observability-startup-owner.test.ts | 26 + .../telemetry-observability-startup-owner.ts | 20 + .../terminal-runtime-startup-boundary.test.ts | 130 +++ ...erminal-runtime-startup-capability.test.ts | 48 + .../terminal-runtime-startup-capability.ts | 44 + .../terminal-runtime-startup-owner.test.ts | 44 + .../startup/terminal-runtime-startup-owner.ts | 20 + .../updater-runtime-startup-boundary.test.ts | 130 +++ ...updater-runtime-startup-capability.test.ts | 32 + .../updater-runtime-startup-capability.ts | 31 + .../updater-runtime-startup-owner.test.ts | 33 + .../startup/updater-runtime-startup-owner.ts | 16 + src/main/stats/collector.ts | 31 +- src/main/stats/stats-file-path.test.ts | 32 + src/main/stats/stats-file-path.ts | 16 + src/main/updater-quit-state.test.ts | 21 + src/main/updater-quit-state.ts | 13 + src/main/updater.ts | 15 +- .../attach-main-window-services.test.ts | 10 +- .../window/attach-main-window-services.ts | 3 +- src/main/window/createMainWindow.test.ts | 13 +- src/main/window/createMainWindow.ts | 6 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 1 + .../startup-diagnostics-contract.test.ts | 90 ++ src/renderer/src/App.tsx | 13 +- src/renderer/src/app-startup-routing.test.ts | 12 + .../AgentTerminalDialog.test.tsx | 61 +- .../dashboard-popout/AgentTerminalDialog.tsx | 20 +- ...ent-terminal-preview-lazy-boundary.test.ts | 42 + .../src/components/settings/Settings.tsx | 2 + .../settings/settings-module-loader.ts | 1 + .../sidebar/LinearAgentSkillSetupDialog.tsx | 2 + ...ntSkillSetupPrompt.reminder-toast.test.tsx | 1 + .../LinearAgentSkillSetupPrompt.test.tsx | 15 +- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 132 +-- ...ntSkillSetupPrompt.update-command.test.tsx | 1 + .../skills/SkillFreshnessNudge.test.tsx | 2 +- .../components/skills/SkillFreshnessNudge.tsx | 2 +- .../skills/SkillFreshnessStatusPill.test.tsx | 2 +- .../skills/SkillFreshnessStatusPill.tsx | 2 +- .../SkillFreshnessUpdateDialog.test.tsx | 68 +- .../skills/SkillFreshnessUpdateDialog.tsx | 54 +- .../skills/SkillFreshnessUpdateDialogHost.tsx | 64 ++ .../src/components/skills/SkillUpdateRow.tsx | 33 +- ...hness-update-dialog-lazy-boundary.test.tsx | 171 ++++ .../skills/skill-freshness-update-dialog.ts | 19 + .../PinnedTabCloseDialogHost.tsx | 33 + ...ed-tab-close-dialog-lazy-boundary.test.tsx | 171 ++++ .../terminal-pane/terminal-appearance.test.ts | 54 +- .../terminal-pane/terminal-appearance.ts | 80 +- ...l-view-attributes-startup-boundary.test.ts | 47 + .../terminal-pane/terminal-view-start.ts | 76 ++ src/renderer/src/hooks/skill-freshness.ts | 178 ++++ src/renderer/src/hooks/useSkillFreshness.ts | 180 +--- src/renderer/src/main.tsx | 2 + .../main-window-startup-milestones.test.ts | 70 ++ .../main-window-startup-milestones.tsx | 86 ++ .../src/startup/startup-diagnostics.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 1 + tests/e2e/main-thread-diagnostic-interval.ts | 68 ++ .../source-control-large-file-count.spec.ts | 48 + .../benchmark-artifact-home-sanitizer.mjs | 56 ++ ...benchmark-artifact-home-sanitizer.test.mjs | 122 +++ .../main-thread-diagnostic-report.mjs | 73 ++ .../main-thread-diagnostic-report.test.mjs | 75 ++ tools/benchmarks/main-thread-jank-bench.mjs | 110 ++- ...-terminal-phase0-diagnostics-2026-07-29.md | 94 ++ .../phase0-startup-attribution-2026-07-29.md | 97 ++ ...ase0-subprocess-rpc-evidence-2026-07-29.md | 109 ++ ...oordination-startup-boundary-2026-07-29.md | 174 ++++ ...nt-services-startup-boundary-2026-07-29.md | 200 ++++ ...agent-awake-startup-boundary-2026-07-29.md | 89 ++ ...ent-browser-startup-boundary-2026-07-29.md | 84 ++ ...ook-runtime-startup-boundary-2026-07-29.md | 198 ++++ ...ion-service-startup-boundary-2026-07-29.md | 136 +++ ...wser-kernel-startup-boundary-2026-07-29.md | 180 ++++ ...usage-store-startup-boundary-2026-07-29.md | 145 +++ ...se1-cli-wsl-startup-boundary-2026-07-29.md | 163 +++ ...nch-session-startup-boundary-2026-07-29.md | 215 ++++ ...usage-store-startup-boundary-2026-07-29.md | 150 +++ ...pc-registry-startup-boundary-2026-07-29.md | 179 ++++ ...ang-runtime-startup-boundary-2026-07-29.md | 184 ++++ ...ut-root-store-boundary-audit-2026-07-30.md | 180 ++++ ...rminal-preview-lazy-boundary-2026-07-30.md | 151 +++ ...sktop-relay-startup-boundary-2026-07-29.md | 131 +++ ...sktop-shell-startup-boundary-2026-07-29.md | 178 ++++ ...e1-emulator-startup-boundary-2026-07-29.md | 83 ++ ...-keybinding-startup-boundary-2026-07-29.md | 124 +++ ...1-main-i18n-startup-boundary-2026-07-29.md | 143 +++ ...main-window-startup-boundary-2026-07-29.md | 168 ++++ ...een-browser-startup-boundary-2026-07-29.md | 143 +++ ...usage-store-startup-boundary-2026-07-29.md | 134 +++ ...b-close-dialog-lazy-boundary-2026-07-30.md | 192 ++++ ...ugin-system-startup-boundary-2026-07-29.md | 203 ++++ ...onnectivity-startup-boundary-2026-07-29.md | 186 ++++ ...-rpc-server-startup-boundary-2026-07-29.md | 177 ++++ ...ime-service-startup-boundary-2026-07-29.md | 199 ++++ ...e1-sftp-upload-lazy-boundary-2026-07-29.md | 95 ++ ...me-metadata-startup-boundary-2026-07-29.md | 182 ++++ ...-update-dialog-lazy-boundary-2026-07-30.md | 205 ++++ ...e1-star-nag-startup-boundary-2026-07-29.md | 94 ++ ...s-collector-startup-boundary-2026-07-29.md | 137 +++ ...hase1-store-startup-boundary-2026-07-29.md | 204 ++++ ...le-behavior-startup-boundary-2026-07-29.md | 139 +++ ...servability-startup-boundary-2026-07-29.md | 198 ++++ ...nal-runtime-startup-boundary-2026-07-29.md | 210 ++++ ...-attributes-startup-boundary-2026-07-30.md | 177 ++++ ...ter-runtime-startup-boundary-2026-07-29.md | 214 ++++ tools/benchmarks/startup-time-bench.mjs | 194 +--- .../startup-time-benchmark-arguments.mjs | 61 ++ .../startup-time-benchmark-arguments.test.mjs | 21 + .../benchmarks/startup-time-measurements.mjs | 131 +++ .../startup-time-measurements.test.mjs | 80 ++ 296 files changed, 19211 insertions(+), 1419 deletions(-) create mode 100644 build-plugins/startup-diagnostics-bootstrap.ts create mode 100644 config/electron-bundle-budgets.json create mode 100644 config/scripts/electron-bundle-entry-report.mjs create mode 100644 config/scripts/electron-bundle-entry-report.test.mjs create mode 100644 config/scripts/startup-diagnostics-bootstrap.test.ts create mode 100644 docs/readme/performance-architecture-review-2026-07-29.md create mode 100644 mobile/packages/expo-process-memory/android/build.gradle create mode 100644 mobile/packages/expo-process-memory/android/src/main/AndroidManifest.xml create mode 100644 mobile/packages/expo-process-memory/android/src/main/java/expo/modules/processmemory/ExpoProcessMemoryModule.kt create mode 100644 mobile/packages/expo-process-memory/expo-module.config.json create mode 100644 mobile/packages/expo-process-memory/ios/ExpoProcessMemory.podspec create mode 100644 mobile/packages/expo-process-memory/ios/ExpoProcessMemoryModule.swift create mode 100644 mobile/packages/expo-process-memory/package.json create mode 100644 mobile/packages/expo-process-memory/src/ExpoProcessMemoryModule.ts create mode 100644 mobile/packages/expo-process-memory/src/index.ts create mode 100644 mobile/packages/expo-process-memory/src/process-memory-measurement.ts create mode 100644 mobile/packages/expo-process-memory/tsconfig.json create mode 100644 mobile/src/diagnostics/mobile-process-memory-diagnostics.test.ts create mode 100644 mobile/src/diagnostics/mobile-process-memory-diagnostics.ts create mode 100644 mobile/src/session/mobile-terminal-process-memory-diagnostics.ts create mode 100644 mobile/src/session/mobile-terminal-webview-count-diagnostics.test.ts create mode 100644 mobile/src/session/mobile-terminal-webview-count-diagnostics.ts create mode 100644 src/main/browser/browser-kernel-window-dependencies.ts create mode 100644 src/main/claude-usage/claude-usage-file-path.test.ts create mode 100644 src/main/claude-usage/claude-usage-file-path.ts create mode 100644 src/main/codex-usage/codex-usage-file-path.test.ts create mode 100644 src/main/codex-usage/codex-usage-file-path.ts create mode 100644 src/main/diagnostics/main-thread-churn-probe-report.test.ts create mode 100644 src/main/ipc/runtime-environment-request-connections.test.ts create mode 100644 src/main/opencode-usage/opencode-usage-file-path.test.ts create mode 100644 src/main/opencode-usage/opencode-usage-file-path.ts create mode 100644 src/main/persistence-data-path.test.ts create mode 100644 src/main/persistence-data-path.ts create mode 100644 src/main/providers/ssh-filesystem-provider-download-boundary.test.ts create mode 100644 src/main/providers/ssh-filesystem-provider-upload-boundary.test.ts create mode 100644 src/main/providers/ssh-filesystem-sftp-stat.ts create mode 100644 src/main/ssh/sftp-upload-capability.ts create mode 100644 src/main/ssh/ssh-channel-multiplexer-synthetic-latency.benchmark.test.ts create mode 100644 src/main/ssh/ssh-relay-install-transfers.test.ts create mode 100644 src/main/startup/account-runtime-coordination-startup-boundary.test.ts create mode 100644 src/main/startup/account-runtime-coordination-startup-capability.test.ts create mode 100644 src/main/startup/account-runtime-coordination-startup-capability.ts create mode 100644 src/main/startup/account-runtime-coordination-startup-owner.test.ts create mode 100644 src/main/startup/account-runtime-coordination-startup-owner.ts create mode 100644 src/main/startup/account-services-startup-boundary.test.ts create mode 100644 src/main/startup/account-services-startup-capability.test.ts create mode 100644 src/main/startup/account-services-startup-capability.ts create mode 100644 src/main/startup/agent-awake-startup-boundary.test.ts create mode 100644 src/main/startup/agent-awake-startup-capability.test.ts create mode 100644 src/main/startup/agent-awake-startup-capability.ts create mode 100644 src/main/startup/agent-browser-startup-boundary.test.ts create mode 100644 src/main/startup/agent-browser-startup-capability.test.ts create mode 100644 src/main/startup/agent-browser-startup-capability.ts create mode 100644 src/main/startup/agent-hook-runtime-startup-boundary.test.ts create mode 100644 src/main/startup/agent-hook-runtime-startup-capability.test.ts create mode 100644 src/main/startup/agent-hook-runtime-startup-capability.ts create mode 100644 src/main/startup/agent-hook-runtime-startup-owner.test.ts create mode 100644 src/main/startup/agent-hook-runtime-startup-owner.ts create mode 100644 src/main/startup/automation-service-startup-boundary.test.ts create mode 100644 src/main/startup/automation-service-startup-capability.test.ts create mode 100644 src/main/startup/automation-service-startup-capability.ts create mode 100644 src/main/startup/browser-kernel-startup-boundary.test.ts create mode 100644 src/main/startup/browser-kernel-startup-capability.test.ts create mode 100644 src/main/startup/browser-kernel-startup-capability.ts create mode 100644 src/main/startup/claude-usage-store-startup-boundary.test.ts create mode 100644 src/main/startup/claude-usage-store-startup-capability.test.ts create mode 100644 src/main/startup/claude-usage-store-startup-capability.ts create mode 100644 src/main/startup/cli-wsl-startup-boundary.test.ts create mode 100644 src/main/startup/cli-wsl-startup-capability.test.ts create mode 100644 src/main/startup/cli-wsl-startup-capability.ts create mode 100644 src/main/startup/codex-launch-session-startup-boundary.test.ts create mode 100644 src/main/startup/codex-launch-session-startup-capability.test.ts create mode 100644 src/main/startup/codex-launch-session-startup-capability.ts create mode 100644 src/main/startup/codex-launch-session-startup-owner.test.ts create mode 100644 src/main/startup/codex-launch-session-startup-owner.ts create mode 100644 src/main/startup/codex-usage-store-startup-boundary.test.ts create mode 100644 src/main/startup/codex-usage-store-startup-capability.test.ts create mode 100644 src/main/startup/codex-usage-store-startup-capability.ts create mode 100644 src/main/startup/core-ipc-registry-startup-boundary.test.ts create mode 100644 src/main/startup/core-ipc-registry-startup-capability.test.ts create mode 100644 src/main/startup/core-ipc-registry-startup-capability.ts create mode 100644 src/main/startup/crash-hang-runtime-startup-boundary.test.ts create mode 100644 src/main/startup/crash-hang-runtime-startup-capability.test.ts create mode 100644 src/main/startup/crash-hang-runtime-startup-capability.ts create mode 100644 src/main/startup/crash-hang-runtime-startup-owner.test.ts create mode 100644 src/main/startup/crash-hang-runtime-startup-owner.ts create mode 100644 src/main/startup/desktop-relay-service-startup-boundary.test.ts create mode 100644 src/main/startup/desktop-relay-service-startup-capability.test.ts create mode 100644 src/main/startup/desktop-relay-service-startup-capability.ts create mode 100644 src/main/startup/desktop-shell-startup-boundary.test.ts create mode 100644 src/main/startup/desktop-shell-startup-capability.test.ts create mode 100644 src/main/startup/desktop-shell-startup-capability.ts create mode 100644 src/main/startup/desktop-shell-startup-owner.test.ts create mode 100644 src/main/startup/desktop-shell-startup-owner.ts create mode 100644 src/main/startup/emulator-startup-boundary.test.ts create mode 100644 src/main/startup/emulator-startup-capability.test.ts create mode 100644 src/main/startup/emulator-startup-capability.ts create mode 100644 src/main/startup/keybinding-service-startup-boundary.test.ts create mode 100644 src/main/startup/keybinding-service-startup-capability.test.ts create mode 100644 src/main/startup/keybinding-service-startup-capability.ts create mode 100644 src/main/startup/main-window-startup-boundary.test.ts create mode 100644 src/main/startup/main-window-startup-capability.test.ts create mode 100644 src/main/startup/main-window-startup-capability.ts create mode 100644 src/main/startup/main-window-startup-owner.test.ts create mode 100644 src/main/startup/main-window-startup-owner.ts create mode 100644 src/main/startup/offscreen-browser-startup-boundary.test.ts create mode 100644 src/main/startup/offscreen-browser-startup-capability.test.ts create mode 100644 src/main/startup/offscreen-browser-startup-capability.ts create mode 100644 src/main/startup/opencode-usage-store-startup-boundary.test.ts create mode 100644 src/main/startup/opencode-usage-store-startup-capability.test.ts create mode 100644 src/main/startup/opencode-usage-store-startup-capability.ts create mode 100644 src/main/startup/plugin-system-startup-boundary.test.ts create mode 100644 src/main/startup/plugin-system-startup-capability.test.ts create mode 100644 src/main/startup/plugin-system-startup-capability.ts create mode 100644 src/main/startup/runtime-connectivity-startup-boundary.test.ts create mode 100644 src/main/startup/runtime-connectivity-startup-capability.test.ts create mode 100644 src/main/startup/runtime-connectivity-startup-capability.ts create mode 100644 src/main/startup/runtime-connectivity-startup-owner.test.ts create mode 100644 src/main/startup/runtime-connectivity-startup-owner.ts create mode 100644 src/main/startup/runtime-rpc-server-startup-boundary.test.ts create mode 100644 src/main/startup/runtime-rpc-server-startup-capability.test.ts create mode 100644 src/main/startup/runtime-rpc-server-startup-capability.ts create mode 100644 src/main/startup/runtime-service-startup-boundary.test.ts create mode 100644 src/main/startup/runtime-service-startup-capability.test.ts create mode 100644 src/main/startup/runtime-service-startup-capability.ts create mode 100644 src/main/startup/shutdown-watcher-runtime-metadata-startup-boundary.test.ts create mode 100644 src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.test.ts create mode 100644 src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.ts create mode 100644 src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.test.ts create mode 100644 src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.ts create mode 100644 src/main/startup/star-nag-startup-boundary.test.ts create mode 100644 src/main/startup/star-nag-startup-capability.test.ts create mode 100644 src/main/startup/star-nag-startup-capability.ts create mode 100644 src/main/startup/stats-collector-startup-boundary.test.ts create mode 100644 src/main/startup/stats-collector-startup-capability.test.ts create mode 100644 src/main/startup/stats-collector-startup-capability.ts create mode 100644 src/main/startup/store-startup-boundary.test.ts create mode 100644 src/main/startup/store-startup-capability.test.ts create mode 100644 src/main/startup/store-startup-capability.ts create mode 100644 src/main/startup/telemetry-observability-startup-boundary.test.ts create mode 100644 src/main/startup/telemetry-observability-startup-capability.test.ts create mode 100644 src/main/startup/telemetry-observability-startup-capability.ts create mode 100644 src/main/startup/telemetry-observability-startup-owner.test.ts create mode 100644 src/main/startup/telemetry-observability-startup-owner.ts create mode 100644 src/main/startup/terminal-runtime-startup-boundary.test.ts create mode 100644 src/main/startup/terminal-runtime-startup-capability.test.ts create mode 100644 src/main/startup/terminal-runtime-startup-capability.ts create mode 100644 src/main/startup/terminal-runtime-startup-owner.test.ts create mode 100644 src/main/startup/terminal-runtime-startup-owner.ts create mode 100644 src/main/startup/updater-runtime-startup-boundary.test.ts create mode 100644 src/main/startup/updater-runtime-startup-capability.test.ts create mode 100644 src/main/startup/updater-runtime-startup-capability.ts create mode 100644 src/main/startup/updater-runtime-startup-owner.test.ts create mode 100644 src/main/startup/updater-runtime-startup-owner.ts create mode 100644 src/main/stats/stats-file-path.test.ts create mode 100644 src/main/stats/stats-file-path.ts create mode 100644 src/main/updater-quit-state.test.ts create mode 100644 src/main/updater-quit-state.ts create mode 100644 src/preload/startup-diagnostics-contract.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-terminal-preview-lazy-boundary.test.ts create mode 100644 src/renderer/src/components/settings/settings-module-loader.ts create mode 100644 src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx create mode 100644 src/renderer/src/components/skills/skill-freshness-update-dialog-lazy-boundary.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx create mode 100644 src/renderer/src/components/terminal-pane/pinned-tab-close-dialog-lazy-boundary.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-startup-boundary.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-start.ts create mode 100644 src/renderer/src/hooks/skill-freshness.ts create mode 100644 src/renderer/src/startup/main-window-startup-milestones.test.ts create mode 100644 src/renderer/src/startup/main-window-startup-milestones.tsx create mode 100644 tests/e2e/main-thread-diagnostic-interval.ts create mode 100644 tools/benchmarks/benchmark-artifact-home-sanitizer.mjs create mode 100644 tools/benchmarks/benchmark-artifact-home-sanitizer.test.mjs create mode 100644 tools/benchmarks/main-thread-diagnostic-report.mjs create mode 100644 tools/benchmarks/main-thread-diagnostic-report.test.mjs create mode 100644 tools/benchmarks/results/mobile-terminal-phase0-diagnostics-2026-07-29.md create mode 100644 tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md create mode 100644 tools/benchmarks/results/phase0-subprocess-rpc-evidence-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-account-runtime-coordination-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-account-services-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-agent-awake-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-agent-browser-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-agent-hook-runtime-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-automation-service-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-browser-kernel-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-claude-usage-store-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-cli-wsl-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-codex-launch-session-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-codex-usage-store-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-core-ipc-registry-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-crash-hang-runtime-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-dashboard-popout-root-store-boundary-audit-2026-07-30.md create mode 100644 tools/benchmarks/results/phase1-dashboard-terminal-preview-lazy-boundary-2026-07-30.md create mode 100644 tools/benchmarks/results/phase1-desktop-relay-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-desktop-shell-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-emulator-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-keybinding-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-main-i18n-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-main-window-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-offscreen-browser-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-opencode-usage-store-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-pinned-tab-close-dialog-lazy-boundary-2026-07-30.md create mode 100644 tools/benchmarks/results/phase1-plugin-system-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-runtime-connectivity-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-runtime-rpc-server-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-runtime-service-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-sftp-upload-lazy-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-shutdown-watcher-runtime-metadata-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-skill-freshness-update-dialog-lazy-boundary-2026-07-30.md create mode 100644 tools/benchmarks/results/phase1-star-nag-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-stats-collector-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-store-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-synthetic-title-behavior-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-telemetry-observability-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-terminal-runtime-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/results/phase1-terminal-view-attributes-startup-boundary-2026-07-30.md create mode 100644 tools/benchmarks/results/phase1-updater-runtime-startup-boundary-2026-07-29.md create mode 100644 tools/benchmarks/startup-time-benchmark-arguments.mjs create mode 100644 tools/benchmarks/startup-time-benchmark-arguments.test.mjs create mode 100644 tools/benchmarks/startup-time-measurements.mjs create mode 100644 tools/benchmarks/startup-time-measurements.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f5497280d23..6832fe49fc6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -251,6 +251,9 @@ jobs: done exit "$status" + - name: Check Electron bundle budgets + run: pnpm run check:electron-bundle-budgets + - name: Project web client from renderer build run: pnpm run build:web-from-renderer diff --git a/build-plugins/startup-diagnostics-bootstrap.ts b/build-plugins/startup-diagnostics-bootstrap.ts new file mode 100644 index 00000000000..d254cd33d1f --- /dev/null +++ b/build-plugins/startup-diagnostics-bootstrap.ts @@ -0,0 +1,158 @@ +import type { Plugin } from 'vite' + +const COMPLETION_MARKER = '__ORCA_BOOTSTRAP_MARK_EVALUATION_COMPLETE__' + +export function createStartupDiagnosticsBootstrapPrefix(chunkName: string): string { + return ` +;(() => { + const env = typeof process !== 'undefined' ? process.env : undefined + const mode = env?.ORCA_STARTUP_DIAGNOSTICS + if (mode !== '1' && mode !== 'trace') { + return + } + const processNow = () => { + try { + return globalThis.performance.now() + } catch { + return null + } + } + const enteredAt = processNow() + const safeJson = (value) => { + try { + return JSON.stringify(value) + } catch { + return '""' + } + } + let closeSync + let diagnosticFileDescriptor + let openSync + let writeSync + try { + const fs = require('node:fs') + closeSync = fs.closeSync + openSync = fs.openSync + writeSync = fs.writeSync + } catch { + closeSync = undefined + openSync = undefined + writeSync = undefined + } + const diagnosticFile = env?.ORCA_STARTUP_DIAGNOSTICS_FILE + if (typeof diagnosticFile === 'string' && diagnosticFile.length > 0 && typeof openSync === 'function') { + try { + diagnosticFileDescriptor = openSync(diagnosticFile, 'a', 0o600) + } catch { + diagnosticFileDescriptor = undefined + } + } + const writeLine = (message) => { + try { + const line = message.endsWith('\\n') ? message : message + '\\n' + if (typeof writeSync === 'function') { + writeSync(2, line) + if (typeof diagnosticFileDescriptor === 'number') { + writeSync(diagnosticFileDescriptor, line) + } + } + } catch { + // Diagnostics must never affect startup. + } + } + const chunkName = ${JSON.stringify(chunkName)} + writeLine('[bootstrap] bundle-enter t=' + safeJson(enteredAt) + ' clock="process-performance-now-ms" chunk=' + safeJson(chunkName) + ' pid=' + process.pid + ' ppid=' + process.ppid + ' execPath=' + safeJson(process.execPath) + ' argv=' + safeJson(process.argv) + ' electronRunAsNode=' + safeJson(env?.ELECTRON_RUN_AS_NODE ?? null)) + globalThis.${COMPLETION_MARKER} = () => { + writeLine('[bootstrap] bundle-evaluation-complete t=' + safeJson(processNow()) + ' clock="process-performance-now-ms" chunk=' + safeJson(chunkName)) + } + if (!globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__) { + globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__ = true + process.once('exit', (code) => { + writeLine('[bootstrap] process-exit code=' + code) + if (typeof closeSync === 'function' && typeof diagnosticFileDescriptor === 'number') { + try { + closeSync(diagnosticFileDescriptor) + } catch { + // Diagnostics must never affect shutdown. + } + } + }) + process.on('uncaughtExceptionMonitor', (error, origin) => { + const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error + writeLine('[bootstrap] uncaught-exception origin=' + safeJson(origin) + ' error=' + safeJson(String(message))) + }) + process.on('unhandledRejection', (reason) => { + const message = reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason + writeLine('[bootstrap] unhandled-rejection error=' + safeJson(String(message))) + }) + } + if (mode === 'trace' && !globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__) { + globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__ = true + try { + const Module = require('node:module') + const originalLoad = Module._load + const parsedTraceLimit = Number(env?.ORCA_STARTUP_DIAGNOSTICS_TRACE_LIMIT ?? 20000) + const traceLimit = Number.isFinite(parsedTraceLimit) && parsedTraceLimit > 0 ? parsedTraceLimit : 20000 + let traceLineCount = 0 + let traceLimitReported = false + const writeTraceLine = (message) => { + if (traceLineCount >= traceLimit) { + if (!traceLimitReported) { + traceLimitReported = true + writeLine('[bootstrap] require-trace-limit-reached limit=' + safeJson(traceLimit)) + } + return + } + traceLineCount += 1 + writeLine(message) + } + Module._load = function (request, parent, isMain) { + const parentName = parent && parent.filename ? parent.filename : null + writeTraceLine('[bootstrap] require-start request=' + safeJson(request) + ' parent=' + safeJson(parentName) + ' isMain=' + safeJson(Boolean(isMain))) + try { + const result = Reflect.apply(originalLoad, this, arguments) + writeTraceLine('[bootstrap] require-ok request=' + safeJson(request)) + return result + } catch (error) { + const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error + writeTraceLine('[bootstrap] require-error request=' + safeJson(request) + ' error=' + safeJson(String(message))) + throw error + } + } + } catch (error) { + writeLine('[bootstrap] require-trace-install-error error=' + safeJson(String(error))) + } + } +})(); +` +} + +export function createStartupDiagnosticsBootstrapSuffix(): string { + return ` +;(() => { + try { + globalThis.${COMPLETION_MARKER}?.() + } catch { + // Diagnostics must never affect startup. + } +})(); +` +} + +export function createStartupDiagnosticsBootstrapPlugin(): Plugin { + return { + name: 'orca-startup-diagnostics-bootstrap', + generateBundle(_options, bundle) { + const mainChunk = bundle['index.js'] + if (!mainChunk || mainChunk.type !== 'chunk') { + return + } + + // Why: source diagnostics run after generated imports; wrap the final entry instead. + mainChunk.code = + createStartupDiagnosticsBootstrapPrefix(mainChunk.fileName) + + mainChunk.code + + createStartupDiagnosticsBootstrapSuffix() + } + } +} diff --git a/config/electron-bundle-budgets.json b/config/electron-bundle-budgets.json new file mode 100644 index 00000000000..031eadb6e53 --- /dev/null +++ b/config/electron-bundle-budgets.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "entries": { + "electron-main": { + "maxRawBytes": 825109, + "maxJavaScriptFiles": 1, + "maxCssFiles": 0 + }, + "electron-preload": { + "maxRawBytes": 132000, + "maxJavaScriptFiles": 1, + "maxCssFiles": 0 + }, + "renderer-index": { + "maxRawBytes": 8466347, + "maxJavaScriptFiles": 293, + "maxCssFiles": 2 + }, + "renderer-popout": { + "maxRawBytes": 4537948, + "maxJavaScriptFiles": 78, + "maxCssFiles": 2 + }, + "renderer-web": { + "maxRawBytes": 4384876, + "maxJavaScriptFiles": 34, + "maxCssFiles": 1 + } + } +} diff --git a/config/scripts/electron-bundle-entry-report.mjs b/config/scripts/electron-bundle-entry-report.mjs new file mode 100644 index 00000000000..264f636cc21 --- /dev/null +++ b/config/scripts/electron-bundle-entry-report.mjs @@ -0,0 +1,223 @@ +import { readFileSync } from 'node:fs' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { gzipSync } from 'node:zlib' + +const TOP_CONTRIBUTOR_COUNT = 10 +const RENDERER_ENTRIES = [ + { id: 'renderer-index', manifestKey: 'index.html' }, + { id: 'renderer-popout', manifestKey: 'popout.html' }, + { id: 'renderer-web', manifestKey: 'web-index.html' } +] + +function displayPath(filePath) { + return filePath.split(sep).join('/') +} + +function readArtifact(projectRoot, projectPath) { + const absolutePath = join(projectRoot, ...projectPath.split('/')) + + try { + return readFileSync(absolutePath) + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error(`Missing build artifact: ${projectPath}`) + } + throw error + } +} + +function resolveRendererArtifact(projectRoot, manifestPath) { + if ( + isAbsolute(manifestPath) || + /^[A-Za-z]:[\\/]/.test(manifestPath) || + manifestPath.split(/[\\/]/).includes('..') + ) { + throw new Error(`Invalid renderer artifact path: ${manifestPath}`) + } + + const rendererRoot = join(projectRoot, 'out', 'renderer') + const absolutePath = resolve(rendererRoot, ...manifestPath.split('/')) + const relativePath = relative(projectRoot, absolutePath) + return { + absolutePath, + reportPath: displayPath(relativePath) + } +} + +function measureFile(buffer, reportPath, type) { + return { + path: reportPath, + type, + rawBytes: buffer.byteLength, + gzipBytes: gzipSync(buffer).byteLength + } +} + +function comparePaths(left, right) { + if (left < right) { + return -1 + } + if (left > right) { + return 1 + } + return 0 +} + +function summarizeEntry(id, entryFile, files) { + const contributors = [...files].sort( + (left, right) => right.rawBytes - left.rawBytes || comparePaths(left.path, right.path) + ) + + return { + id, + entryFile, + rawBytes: contributors.reduce((total, file) => total + file.rawBytes, 0), + gzipBytes: contributors.reduce((total, file) => total + file.gzipBytes, 0), + javascriptFiles: contributors.filter((file) => file.type === 'javascript').length, + cssFiles: contributors.filter((file) => file.type === 'css').length, + topContributors: contributors.slice(0, TOP_CONTRIBUTOR_COUNT) + } +} + +function collectRendererFiles(manifest, entryKey) { + const visitedChunks = new Set() + const javascriptFiles = new Set() + const cssFiles = new Set() + + function visit(chunkKey) { + if (visitedChunks.has(chunkKey)) { + return + } + visitedChunks.add(chunkKey) + + const chunk = manifest[chunkKey] + if (!chunk) { + throw new Error(`Missing manifest chunk "${chunkKey}" reached from "${entryKey}"`) + } + if (typeof chunk.file !== 'string') { + throw new Error(`Manifest chunk "${chunkKey}" has no output file`) + } + + javascriptFiles.add(chunk.file) + for (const cssFile of chunk.css ?? []) { + cssFiles.add(cssFile) + } + for (const importedChunk of chunk.imports ?? []) { + visit(importedChunk) + } + } + + visit(entryKey) + return { javascriptFiles, cssFiles } +} + +function readRendererArtifact(absolutePath, reportPath) { + try { + return readFileSync(absolutePath) + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error(`Missing build artifact: ${reportPath}`) + } + throw error + } +} + +function measureRendererEntry(projectRoot, manifest, { id, manifestKey }) { + const { javascriptFiles, cssFiles } = collectRendererFiles(manifest, manifestKey) + const files = [] + + for (const manifestPath of [...javascriptFiles].sort()) { + const { absolutePath, reportPath } = resolveRendererArtifact(projectRoot, manifestPath) + files.push( + measureFile(readRendererArtifact(absolutePath, reportPath), reportPath, 'javascript') + ) + } + for (const manifestPath of [...cssFiles].sort()) { + const { absolutePath, reportPath } = resolveRendererArtifact(projectRoot, manifestPath) + files.push(measureFile(readRendererArtifact(absolutePath, reportPath), reportPath, 'css')) + } + + return summarizeEntry(id, manifestKey, files) +} + +export function createElectronBundleEntryReport(projectRoot = process.cwd()) { + projectRoot = resolve(projectRoot) + const manifestPath = 'out/renderer/.vite/manifest.json' + const manifestBuffer = readArtifact(projectRoot, manifestPath) + let manifest + + try { + manifest = JSON.parse(manifestBuffer.toString('utf8')) + } catch { + throw new Error(`Invalid JSON build artifact: ${manifestPath}`) + } + + const singleFileEntries = [ + { id: 'electron-main', path: 'out/main/index.js' }, + { id: 'electron-preload', path: 'out/preload/index.js' } + ].map(({ id, path }) => + summarizeEntry(id, path, [measureFile(readArtifact(projectRoot, path), path, 'javascript')]) + ) + + return { + version: 1, + entries: [ + ...singleFileEntries, + ...RENDERER_ENTRIES.map((entry) => measureRendererEntry(projectRoot, manifest, entry)) + ] + } +} + +export function findElectronBundleBudgetFailures(report, budgets) { + const failures = [] + + for (const entry of report.entries) { + const budget = budgets.entries?.[entry.id] + if (!budget) { + failures.push(`${entry.id}: missing checked-in budget`) + continue + } + + const checks = [ + ['raw bytes', entry.rawBytes, budget.maxRawBytes], + ['JavaScript files', entry.javascriptFiles, budget.maxJavaScriptFiles], + ['CSS files', entry.cssFiles, budget.maxCssFiles] + ] + for (const [label, actual, limit] of checks) { + if (!Number.isSafeInteger(limit) || limit < 0) { + failures.push(`${entry.id}: invalid ${label} budget`) + } else if (actual > limit) { + failures.push(`${entry.id}: ${label} ${actual} exceeds budget ${limit}`) + } + } + } + + return failures +} + +export function runElectronBundleBudgetCheck({ + projectRoot = process.cwd(), + budgetsPath = join(projectRoot, 'config', 'electron-bundle-budgets.json'), + writeReport = console.log, + writeError = console.error +} = {}) { + try { + const report = createElectronBundleEntryReport(projectRoot) + const budgets = JSON.parse(readFileSync(budgetsPath, 'utf8')) + const failures = findElectronBundleBudgetFailures(report, budgets) + writeReport(JSON.stringify(report, null, 2)) + for (const failure of failures) { + writeError(`Bundle budget exceeded: ${failure}`) + } + return failures.length === 0 ? 0 : 1 + } catch (error) { + writeError(error instanceof Error ? error.message : String(error)) + return 1 + } +} + +const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url +if (isMain) { + process.exitCode = runElectronBundleBudgetCheck() +} diff --git a/config/scripts/electron-bundle-entry-report.test.mjs b/config/scripts/electron-bundle-entry-report.test.mjs new file mode 100644 index 00000000000..b9aad697778 --- /dev/null +++ b/config/scripts/electron-bundle-entry-report.test.mjs @@ -0,0 +1,177 @@ +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createElectronBundleEntryReport, + findElectronBundleBudgetFailures, + runElectronBundleBudgetCheck +} from './electron-bundle-entry-report.mjs' + +let projectRoot + +async function writeProjectFile(projectPath, contents) { + const absolutePath = join(projectRoot, ...projectPath.split('/')) + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, contents) +} + +async function writeFixtureBuild() { + const files = { + 'out/main/index.js': 'main-entry', + 'out/preload/index.js': 'preload-entry', + 'out/renderer/assets/index.js': 'index-entry', + 'out/renderer/assets/first.js': 'first-chunk', + 'out/renderer/assets/shared.js': 'shared-chunk', + 'out/renderer/assets/popout.js': 'popout-entry', + 'out/renderer/assets/web.js': 'web-entry', + 'out/renderer/assets/index.css': 'index-style', + 'out/renderer/assets/shared.css': 'shared-style' + } + const manifest = { + 'index.html': { + file: 'assets/index.js', + imports: ['_first.js', '_shared.js'], + dynamicImports: ['_dynamic.js'], + css: ['assets/index.css'] + }, + '_first.js': { + file: 'assets/first.js', + imports: ['_shared.js'], + css: ['assets/shared.css'] + }, + '_shared.js': { + file: 'assets/shared.js', + css: ['assets/shared.css'] + }, + 'popout.html': { + file: 'assets/popout.js', + imports: ['_shared.js'] + }, + 'web-index.html': { + file: 'assets/web.js', + imports: ['_shared.js'] + }, + '_dynamic.js': { + file: 'assets/not-written-dynamic.js' + } + } + + for (const [projectPath, contents] of Object.entries(files)) { + await writeProjectFile(projectPath, contents) + } + await writeProjectFile('out/renderer/.vite/manifest.json', JSON.stringify(manifest)) + return files +} + +function byteLength(value) { + return Buffer.byteLength(value) +} + +function gzipLength(value) { + return gzipSync(value).byteLength +} + +beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), 'orca-electron-bundle-report-')) +}) + +afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }) +}) + +describe('Electron bundle entry report', () => { + it('follows transitive static imports and ignores dynamic imports', async () => { + const files = await writeFixtureBuild() + const report = createElectronBundleEntryReport(projectRoot) + const entry = report.entries.find(({ id }) => id === 'renderer-index') + const expectedPaths = [ + 'out/renderer/assets/index.js', + 'out/renderer/assets/first.js', + 'out/renderer/assets/shared.js', + 'out/renderer/assets/index.css', + 'out/renderer/assets/shared.css' + ] + + expect(entry.javascriptFiles).toBe(3) + expect(entry.cssFiles).toBe(2) + expect(entry.rawBytes).toBe( + expectedPaths.reduce((total, projectPath) => total + byteLength(files[projectPath]), 0) + ) + expect(entry.gzipBytes).toBe( + expectedPaths.reduce((total, projectPath) => total + gzipLength(files[projectPath]), 0) + ) + }) + + it('deduplicates shared JavaScript and CSS within each entry', async () => { + await writeFixtureBuild() + const report = createElectronBundleEntryReport(projectRoot) + const entry = report.entries.find(({ id }) => id === 'renderer-index') + const paths = entry.topContributors.map(({ path }) => path) + + expect(paths.filter((path) => path.endsWith('shared.js'))).toHaveLength(1) + expect(paths.filter((path) => path.endsWith('shared.css'))).toHaveLength(1) + }) + + it('fails clearly when a referenced production artifact is missing', async () => { + await writeFixtureBuild() + await unlink(join(projectRoot, 'out', 'renderer', 'assets', 'shared.js')) + + expect(() => createElectronBundleEntryReport(projectRoot)).toThrow(/shared\.js/) + }) + + it('reports raw-size and chunk-count budget failures', async () => { + await writeFixtureBuild() + const report = createElectronBundleEntryReport(projectRoot) + const budgets = { + entries: Object.fromEntries( + report.entries.map((entry) => [ + entry.id, + { + maxRawBytes: entry.rawBytes, + maxJavaScriptFiles: entry.javascriptFiles, + maxCssFiles: entry.cssFiles + } + ]) + ) + } + budgets.entries['renderer-index'].maxRawBytes -= 1 + budgets.entries['renderer-index'].maxJavaScriptFiles -= 1 + + expect(findElectronBundleBudgetFailures(report, budgets)).toEqual([ + expect.stringContaining('raw bytes'), + expect.stringContaining('JavaScript files') + ]) + }) + + it('returns a nonzero gate result when a checked-in budget is exceeded', async () => { + await writeFixtureBuild() + const budgetsPath = join(projectRoot, 'budgets.json') + const report = createElectronBundleEntryReport(projectRoot) + const budgets = { + entries: Object.fromEntries( + report.entries.map((entry) => [ + entry.id, + { + maxRawBytes: entry.id === 'electron-main' ? 0 : entry.rawBytes, + maxJavaScriptFiles: entry.javascriptFiles, + maxCssFiles: entry.cssFiles + } + ]) + ) + } + const errors = [] + await writeFile(budgetsPath, JSON.stringify(budgets)) + + expect( + runElectronBundleBudgetCheck({ + projectRoot, + budgetsPath, + writeReport: () => {}, + writeError: (message) => errors.push(message) + }) + ).toBe(1) + expect(errors).toEqual([expect.stringContaining('electron-main')]) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index c47a7bdb823..bcc565082e7 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -91,6 +91,20 @@ describe('PR workflow parallelism', () => { expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer') }) + it('checks bundle budgets after the Electron build without rebuilding', () => { + const steps = workflow.jobs.package.steps + const buildIndex = steps.findIndex((step) => step.name === 'Build package inputs') + const budgetIndex = steps.findIndex((step) => step.name === 'Check Electron bundle budgets') + const webIndex = steps.findIndex( + (step) => step.name === 'Project web client from renderer build' + ) + + expect(steps[budgetIndex].run).toBe('pnpm run check:electron-bundle-budgets') + expect(buildIndex).toBeLessThan(budgetIndex) + expect(budgetIndex).toBeLessThan(webIndex) + expect(packageJson.scripts['check:electron-bundle-budgets']).not.toContain('build:') + }) + it('restores the pnpm store before dependency installation', () => { const steps = dependencyAction.runs.steps const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm') diff --git a/config/scripts/startup-diagnostics-bootstrap.test.ts b/config/scripts/startup-diagnostics-bootstrap.test.ts new file mode 100644 index 00000000000..837d3bdd6b1 --- /dev/null +++ b/config/scripts/startup-diagnostics-bootstrap.test.ts @@ -0,0 +1,111 @@ +import vm from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { + createStartupDiagnosticsBootstrapPrefix, + createStartupDiagnosticsBootstrapSuffix +} from '../../build-plugins/startup-diagnostics-bootstrap' + +function executeBootstrap(mode: string | undefined, applicationCode = '') { + const lines: string[] = [] + const order: string[] = [] + const originalLoad = vi.fn() + const moduleApi = { _load: originalLoad } + const requireModule = vi.fn((id: string) => { + if (id === 'node:fs') { + return { + closeSync: vi.fn(), + openSync: vi.fn(), + writeSync: (_fd: number, line: string) => { + lines.push(line.trimEnd()) + order.push(line.includes('bundle-enter') ? 'bundle-enter' : 'evaluation-complete') + } + } + } + if (id === 'node:module') { + return moduleApi + } + throw new Error(`Unexpected require: ${id}`) + }) + const now = vi.fn().mockReturnValueOnce(4.25).mockReturnValueOnce(19.75) + const processApi = { + argv: ['electron', 'app with spaces'], + env: mode ? { ORCA_STARTUP_DIAGNOSTICS: mode } : {}, + execPath: '/Applications/Orca App', + on: vi.fn(), + once: vi.fn(), + pid: 12, + ppid: 7 + } + const record = (event: string) => order.push(event) + const source = + createStartupDiagnosticsBootstrapPrefix('index.js') + + applicationCode + + createStartupDiagnosticsBootstrapSuffix() + + const run = () => + vm.runInNewContext(source, { + console, + performance: { now }, + process: processApi, + record, + require: requireModule + }) + + return { lines, moduleApi, now, order, originalLoad, requireModule, run } +} + +describe('startup diagnostics bootstrap', () => { + it('wraps synchronous entry evaluation with ordered process-clock timestamps', () => { + const execution = executeBootstrap('1', "record('application-evaluated');") + + execution.run() + + expect(execution.order).toEqual([ + 'bundle-enter', + 'application-evaluated', + 'evaluation-complete' + ]) + expect(execution.lines[0]).toContain( + '[bootstrap] bundle-enter t=4.25 clock="process-performance-now-ms"' + ) + expect(execution.lines[1]).toContain( + '[bootstrap] bundle-evaluation-complete t=19.75 clock="process-performance-now-ms"' + ) + }) + + it('does not emit evaluation completion when entry evaluation throws', () => { + const execution = executeBootstrap('1', "throw new Error('entry failed');") + + expect(execution.run).toThrow('entry failed') + expect(execution.lines.some((line) => line.includes('bundle-evaluation-complete'))).toBe(false) + }) + + it('never loads or patches Module in normal diagnostics mode', () => { + const execution = executeBootstrap('1') + + execution.run() + + expect(execution.requireModule).toHaveBeenCalledWith('node:fs') + expect(execution.requireModule).not.toHaveBeenCalledWith('node:module') + expect(execution.moduleApi._load).toBe(execution.originalLoad) + }) + + it('installs require tracing only in trace mode', () => { + const execution = executeBootstrap('trace') + + execution.run() + + expect(execution.requireModule).toHaveBeenCalledWith('node:module') + expect(execution.moduleApi._load).not.toBe(execution.originalLoad) + }) + + it('does no diagnostic work when disabled', () => { + const execution = executeBootstrap(undefined, "record('application-evaluated');") + + execution.run() + + expect(execution.order).toEqual(['application-evaluated']) + expect(execution.requireModule).not.toHaveBeenCalled() + expect(execution.now).not.toHaveBeenCalled() + }) +}) diff --git a/docs/readme/performance-architecture-review-2026-07-29.md b/docs/readme/performance-architecture-review-2026-07-29.md new file mode 100644 index 00000000000..9360579279f --- /dev/null +++ b/docs/readme/performance-architecture-review-2026-07-29.md @@ -0,0 +1,766 @@ +# Orca Performance Architecture Review + +- **Date:** 2026-07-29 +- **Revision reviewed:** `ef55429f3d` (`origin/main` at the start of the review) +- **Scope:** Electron main/preload/renderer, shared/runtime/CLI, desktop host + services, mobile, build outputs, and the performance diagnostics and benchmark + suite + +## Executive conclusion + +Orca's next large performance gains will not come from adding another memo or +virtualizing another list. The codebase already does those things well. The +highest-leverage work is to reduce duplicated ownership and make runtime +capabilities genuinely lazy. + +The two strongest opportunities are: + +1. **Build a minimal startup kernel and app shell.** The production main entry is + an eager 8.15 MB JavaScript file, while the primary renderer entry statically + reaches 11.10 MB across 304 JavaScript chunks. Terminal, onboarding, provider, + plugin, account, and integration graphs are reachable before their surfaces + are used. +2. **Give each host one authoritative repository-intelligence service.** Git + status, upstream state, worktree identity, checks eligibility, and related + projections are still fetched through partially independent pipelines. + Coalescing within a single function does not prevent separate consumers from + launching overlapping local, WSL, SSH, or runtime work. + +Four follow-ups are worthwhile after those foundations: + +- partition the renderer store by lifecycle and update frequency; +- cold-park inactive mobile terminal WebViews; +- make the mobile workspace catalog a revisioned, replayable stream; +- partition durable persistence when real profiles, not synthetic restored tabs, + show serialization or shutdown stalls. + +The first three recommendations reinforce one another. Domain stores create the +renderer's lazy boundaries; a capability kernel creates the main-process +boundaries; repository intelligence removes the largest remaining repeated +subprocess/network pipeline. + +## Ranked opportunities + +| Rank | Opportunity | Primary win | Confidence | Effort | +| --------- | ------------------------------------------------------ | ---------------------------------------- | --------------------------------------------- | ------------ | +| P0 | Minimal startup kernel and renderer app shell | cold start, first paint, memory | High | Large | +| P0 | Host-owned repository intelligence | Git CPU, SSH latency, consistency | High | Large | +| P1 | Lifecycle-partitioned renderer stores | startup graph, memory, update isolation | High for startup; Medium for steady-state CPU | Large | +| P1 | Mobile terminal hot set and cold parking | mobile memory/GPU/process pressure | High | Medium–Large | +| P2 | Revisioned mobile workspace stream | foreground network/CPU, remote latency | High | Medium–Large | +| P2 | Partitioned durable persistence | large-profile save/shutdown tail latency | Medium | Large | +| Quick win | Repair false dynamic boundaries and add bundle budgets | startup/package weight | High | Small–Medium | + +“Confidence” describes the evidence that the cost exists, not a promise of a +specific speedup. Only an A/B implementation can establish savings. + +## Measured baseline + +### Codebase and build + +The architectural sweep covered 5,913 non-test TypeScript/TSX files. Approximate +production line counts were 361k in `src/main`, 585k in the renderer, 81k shared, +9k preload, 14k CLI, and 110k mobile. Generated code and tests were excluded +from those figures. + +`pnpm run build:electron-vite` produced: + +| Surface | Static startup graph | Raw bytes | Gzip bytes | +| -------------------- | -------------------: | ---------: | ---------: | +| Electron main entry | one primary entry | 8,149,600 | 1,701,691 | +| Electron preload | one entry | 130,769 | 20,658 | +| Main renderer window | 304 JS + 3 CSS | 11,104,115 | 2,472,847 | +| Dashboard popout | 81 JS + 3 CSS | 5,893,311 | 1,296,803 | +| Web entry | 33 JS + 1 CSS | 4,361,095 | 928,572 | + +The renderer figures are transitive **static** imports from the Vite manifest; +they exclude dynamic imports and large workers such as the 11.35 MB TypeScript +worker. + +Largest chunks in the main renderer startup graph: + +| Chunk role | Raw bytes | +| -------------------------------- | --------: | +| Root Zustand store | 1,950,409 | +| `App` | 1,582,599 | +| terminal user-input graph | 1,347,224 | +| onboarding inline-terminal graph | 1,321,452 | +| JSX runtime group | 663,192 | +| client group | 465,648 | +| shared library group | 343,714 | +| worktree card group | 294,115 | + +The names are build chunk labels, not a claim that every byte belongs to the +named source module. The important fact is that each entire chunk is in the +static entry graph. + +### Startup benchmark + +A three-iteration empty-state production run reported these medians: + +| Phase | Median | +| ---------------------------------- | -----: | +| spawn → Electron app ready | 456 ms | +| app ready → services initialized | 43 ms | +| window created → `did-finish-load` | 159 ms | +| spawn → `did-finish-load` | 768 ms | +| spawn → workspace ready | 911 ms | + +The first run after the build was materially colder: + +| Phase | First run | +| -------------------------------- | --------: | +| spawn → Electron app ready | 2,387 ms | +| app ready → services initialized | 238 ms | +| spawn → `did-finish-load` | 2,886 ms | +| spawn → workspace ready | 3,024 ms | + +The harness does not isolate Electron boot from main-bundle parse, module link, +and top-level evaluation. It therefore supports “startup is a target,” but not +“the bundle costs exactly N milliseconds.” The proposed rollout requires an A/B +split build and finer milestones. + +### Phase 0 startup clock attribution + +`ORCA_STARTUP_DIAGNOSTICS=1` now emits these additional one-shot boundaries: + +| Event | Meaning | Clock | +| ---------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `[bootstrap] bundle-enter` | Earliest executable generated main-entry boundary | Main-process `performance.now()` in `t` | +| `[bootstrap] bundle-evaluation-complete` | Generated main entry and its synchronous dependencies finished evaluation | Main-process `performance.now()` in `t` | +| `[startup] renderer-first-react-commit` | Main-window root layout effect ran after its first React commit | Main-process receipt in `t`; renderer clock in `rendererT` | +| `[startup] renderer-shell-painted` | A second animation-frame callback ran, so a paint opportunity followed the commit | Main-process receipt in `t`; renderer clock in `rendererT` | + +The bootstrap and `[startup]` `t` values share the Electron main process's +monotonic clock. `rendererT` values share only the main-window renderer clock, +so they are used for commit-to-paint duration but not subtracted from main +timestamps. Popout and web roots do not emit these renderer milestones. + +JavaScript cannot separately time parsing or linking before its first +executable statement. `spawnToBundleEnterMs` uses the parent harness's +complete-line receipt and still includes Electron boot, bundle parse/link, and +stderr delivery; it is an upper-bound attribution, not a parse-only timer. +`synchronousBundleAndDependencyEvaluationMs` uses the main-process clock from +bundle entry to the generated evaluation-complete footer. The remaining +explicit phases separate evaluation-complete to app-ready, app-ready to +services initialized, services initialized to first React commit, and first +commit to the post-commit shell paint opportunity. + +Validation on the review worktree's fresh production build: + +| Boundary or phase | One-run isolated macOS fixture | +| ------------------------------------------------------------ | -----------------------------: | +| `bundle-enter` main clock | 257.24 ms | +| `bundle-evaluation-complete` main clock | 454.72 ms | +| `app-ready` main clock | 529.7 ms | +| `services-initialized` main clock | 815.8 ms | +| `renderer-first-react-commit` main receipt | 1,101.0 ms | +| `renderer-shell-painted` main receipt | 1,151.1 ms | +| spawn → bundle-enter line receipt | 1,814.6 ms | +| synchronous bundle/dependency evaluation | 197.48 ms | +| app-ready → services initialized | 286.1 ms | +| services initialized → first React commit receipt | 285.2 ms | +| first commit → post-commit paint opportunity, renderer clock | 55 ms | + +All 21 numeric derived phases were non-negative. The portable evidence is in +[`phase0-startup-attribution-2026-07-29.md`](../../tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md). +Against the pre-instrumentation `out/` snapshot, the measured static startup +surfaces changed by +697 raw/+286 gzip bytes for main, +29/+3 for preload, and ++1,464/+425 for the main renderer (+2,190 raw/+714 gzip total). No budget was +raised. + +The production build, bundle-budget check, focused 16-test suite, Node and web +typechecks, targeted oxlint/oxfmt, max-lines ratchet, and `git diff --check` +passed. The live run was one cold macOS iteration from an unpackaged production +Electron build; it is causal evidence, not a representative latency baseline. +Main IPC receipt includes message latency, and double animation-frame +scheduling establishes a post-commit paint opportunity rather than compositor +proof. + +### Restored-state scaling + +Synthetic restored-local-tab profiles did **not** establish persistence as a +startup bottleneck: + +| Restored tabs | State file | JSON parse | store load | `did-finish-load` | +| ------------: | ---------: | ---------: | ---------: | ----------------: | +| 200 | 104 KB | 0 ms | 5 ms | 758 ms | +| 1,000 | 516 KB | 1 ms | 8 ms | 759 ms | + +These are three-run medians and warm-cache dominated. They show that tab count +alone is not enough to justify an urgent persistence rewrite. + +### Zustand selector fan-out + +`pnpm bench:zustand-selector-fanout` measured 2,500 subscribers across 2,000 +unrelated writes: + +- 5,000,000 selector executions; +- 29.65 ms median total; +- 0.0148 ms per write; +- zero render invalidations. + +Store partitioning should therefore be justified first by dependency isolation, +ownership, and real high-frequency workloads—not by the synthetic selector +loop alone. + +## Architecture map + +| Layer | Current ownership | Main performance implication | +| ------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Electron main | one composition root constructs most services | broad eager module graph before first window | +| Preload | narrow IPC bridge | comparatively small; not a priority | +| Renderer | one `App` plus one ~40-slice Zustand root | all entries inherit broad state; desktop entries also inherit terminal/integration dependencies | +| Runtime/CLI | `OrcaRuntimeService` and RPC methods expose host operations | good remote abstraction, but several data domains still lack one snapshot owner | +| Terminal | daemon/main headless authority plus renderer/mobile presentation | desktop is mature; mobile retains every xterm WebView | +| Persistence | one durable state document plus targeted sidecars | good write safety, but whole-document serialization remains | +| Mobile | RPC snapshots plus event streams and safety polls | reliable convergence, but polling compensates for non-replayable events | + +## P0 — Minimal startup kernel and renderer app shell + +### Evidence + +The main entry statically imports persistence, AI Vault cache persistence, +terminal providers, telemetry, runtime/RPC, updater, accounts, browser, +automation, plugins, and many other capabilities from one composition root. +See [`src/main/index.ts`](../../src/main/index.ts#L1). It constructs the account, +runtime, automation, plugin, browser, rate-limit, and integration services +before the `services-initialized` milestone in the `app.whenReady()` path +([`src/main/index.ts`](../../src/main/index.ts#L1866)). + +The build config has separate entries for workers, the terminal daemon, plugin +host, and watchdogs, which is good, but the primary main entry remains 8.15 MB +([`electron.vite.config.ts`](../../electron.vite.config.ts#L191)). + +The renderer root imports `App` directly +([`src/renderer/src/main.tsx`](../../src/renderer/src/main.tsx#L5)), and `App` +retains several terminal modules while only the main `Terminal` component is +lazy ([`src/renderer/src/App.tsx`](../../src/renderer/src/App.tsx#L53), +[`src/renderer/src/App.tsx`](../../src/renderer/src/App.tsx#L337)). The store is +also an eager root dependency of the main, popout, and web entries. + +This explains otherwise surprising build results: + +- a 1.35 MB terminal-labelled chunk is static for both the main window and + popout; +- a 1.32 MB onboarding inline-terminal chunk is static for the main window; +- the web entry pays 1.95 MB for the same desktop store; +- the popout pays 5.89 MB before rendering a much narrower surface. + +Some apparent main-process dynamic boundaries are also defeated by static +imports. For example, `ssh-connection.ts` dynamically imports SFTP/deploy +modules, while other provider and relay modules import the same implementations +statically. Rollup must keep those modules reachable in eager capability graphs. + +### Target architecture + +Create explicit startup stages with enforced dependency rules: + +1. **Process bootstrap:** process flags, crash guards, profile path, single + instance, and startup diagnostics only. +2. **Window kernel:** durable settings required for theme/window behavior, + minimal i18n, IPC router, runtime identity, and a window-ready gate. +3. **Session restore:** only the workspace/session projections required to draw + the initial shell and reconnect visible terminals. +4. **Post-first-paint services:** telemetry, updater, tray enrichments, account + reconciliation, hook repair, plugin discovery, caches, and background heals. +5. **On-demand capability modules:** SSH/SFTP, AI Vault scanners, browser + automation, emulator, speech, hosted-review providers, plugin workers, and + settings-only flows. + +For the renderer, give each HTML entry its own bootstrap state: + +- a small durable shell/settings/workspace projection; +- dynamic terminal activation for terminal surfaces; +- dynamic onboarding/setup code only when shown; +- a popout-specific dashboard store adapter; +- a web-specific runtime/session adapter. + +Lazy React components are not sufficient if a root store, selector, event +registry, or shared component statically imports the same capability. +Dependency-boundary tests should fail when a shell module imports a +terminal-only, onboarding-only, provider-only, or settings-only module. + +### Implementation sequence + +1. Add build output budgets and save a machine-readable static-entry report. +2. Instrument `bundle-enter`, kernel ready, store ready, first React commit, + shell painted, visible terminal requested, and visible terminal revealed. +3. Extract capability registration from `src/main/index.ts` behind async + factories without changing IPC contracts. +4. Split renderer state as described in the P1 section. +5. Move one low-risk capability at a time, starting with settings/onboarding + and inactive provider integrations. +6. Only then defer terminal modules, preserving restored-terminal correctness + and measuring reveal latency separately. + +### Success gates + +Provisional goals for the first architectural tranche: + +- main eager entry below 3 MB raw; +- main-window static renderer graph below 5 MB raw and materially fewer than + 304 requests; +- popout and web entries no longer import the desktop terminal/store graph; +- 25–40% improvement in a repeatable cold-start protocol on representative + macOS and Windows hardware; +- no regression in warm workspace ready, terminal restore, first terminal + reveal, crash recovery, or serve-mode startup. + +The byte and percentage thresholds are starting SLOs, not estimates of savings. + +### Risks and compatibility + +- Keep local, WSL, SSH, relay, and serve-mode registration deterministic even + when modules load late. +- A late IPC module must return a bounded “initializing” result or await one + shared initialization promise; it must not expose partially constructed + services. +- Windows startup and native-module resolution need packaged-app tests because + dynamic chunk paths differ inside ASAR. +- Folder workspaces must not initialize Git-only capabilities merely to draw + the shell. + +## P0 — Host-owned repository intelligence + +### Evidence + +The active renderer status path is already thoughtfully event-driven. It uses +file, terminal, and push signals, a 3-second activity floor, a 60-second safety +interval, visibility gates, slow-task backoff, and one scheduler +([`useGitStatusPolling.ts`](../../src/renderer/src/components/right-sidebar/useGitStatusPolling.ts#L23), +[`useGitStatusPolling.ts`](../../src/renderer/src/components/right-sidebar/useGitStatusPolling.ts#L103)). + +The Checks panel nevertheless owns another status snapshot. Global status-map +changes intentionally act only as invalidation signals, after which Checks +calls `getRuntimeGitStatus()` and may separately call +`getRuntimeGitUpstreamStatus()` +([`ChecksPanel.tsx`](../../src/renderer/src/components/right-sidebar/ChecksPanel.tsx#L1498), +[`ChecksPanel.tsx`](../../src/renderer/src/components/right-sidebar/ChecksPanel.tsx#L1513)). + +Main-process status deduplication has a cancellation-shaped gap. `getStatus()` +joins identical concurrent requests only when no `AbortSignal` is present; a +signalled request goes straight to `runGetStatus()` +([`src/main/git/status.ts`](../../src/main/git/status.ts#L214)). The active poll +passes a signal, while some private snapshot consumers do not. They therefore +cannot share one underlying read. The IPC handler creates per-renderer +cancellation controllers and forwards those signals to local or SSH providers +([`src/main/ipc/filesystem.ts`](../../src/main/ipc/filesystem.ts#L1091)). + +There are strong caches for individual operations—line stats, submodule paths, +upstream-name resolution, negative upstream results, and worktree scans. The +remaining problem is ownership above those caches. Source Control, Files, +Checks, review creation, cleanup, branch identity, and runtime/mobile clients +need projections of the same repository state but can still initiate different +pipelines. + +This is most expensive on SSH/WSL/runtime hosts, where one logical refresh can +mean multiple RPC round trips plus several Git subprocesses. + +### Target architecture + +Create a repository-intelligence service in the process that owns Git execution +for each host. Maintain a versioned snapshot per repository/worktree: + +```ts +type RepositorySnapshot = { + revision: number + generatedAt: number + repositoryIdentity: { head: string | null; branch: string | null } + status: { entries: GitStatusEntry[]; didHitLimit: boolean; lineStatsState: string } + upstream: GitUpstreamStatus | null + conflicts: GitConflictOperation | null + worktreeGraphVersion: number + freshness: Record +} +``` + +Key the owner by execution host, repository identity, worktree path, and +relevant Git options—not path alone. Host scope must distinguish native, each +WSL distro, each SSH provider/session, and each runtime/relay connection. + +Consumers subscribe to projections. Invalidation sources enqueue refresh +reasons: + +- `.git` and worktree metadata watchers; +- terminal command completion/change signals; +- Orca mutations such as commit, checkout, merge, push, and cleanup; +- provider/reconnect events; +- slow safety reconciliation. + +A consumer cancellation should release its **lease**, not cancel a shared +underlying Git read still needed by other consumers. Mutations should retain +the existing pre/post invalidation fences and increment a generation so +pre-mutation results cannot repopulate the snapshot. + +### Rollout + +1. Wrap the current status/upstream functions behind the service without + changing subprocess commands. +2. Migrate Checks and the active polling hook to snapshot projections. +3. Migrate Source Control, creation eligibility, cleanup, and hosted-review + consumers. +4. Publish revisioned snapshots through runtime RPC for mobile/web. +5. Add optional worktree-graph and conflict projections after status ownership + is stable. + +### Success gates + +- One physical status pipeline per host/worktree/generation under concurrent + Source Control + Checks + polling requests. +- No duplicate upstream probe for consumers of the same snapshot. +- Cancellation of one renderer or panel does not cancel work leased elsewhere. +- Subprocess and RPC counts fall materially in a large-repo/slow-SSH benchmark. +- Mutation read-after-write tests remain exact. +- Preferred and fallback commands run in the Git 2.25 compatibility matrix. + +### Risks and compatibility + +- Do not let a TTL substitute for explicit mutation invalidation. +- Do not share capability state across WSL distros, SSH hosts, or runtime + incarnations. +- A folder workspace needs a cheap non-Git snapshot and no Git probes. +- Provider-neutral review fields belong in the snapshot; GitHub-specific + details do not. + +## P1 — Lifecycle-partitioned renderer stores + +### Evidence + +The root Zustand store eagerly creates roughly 40 slices, including workspaces, +terminals, UI, settings, GitHub, hosted review, Linear, Jira, editor, usage, +browser, SSH, runtime, agent status, dictation, cleanup, and generation state +([`src/renderer/src/store/index.ts`](../../src/renderer/src/store/index.ts#L52)). + +Store slices cross capability boundaries: + +- the terminal slice imports parked-watcher, shutdown-buffer, and runtime + terminal-stream modules + ([`src/renderer/src/store/slices/terminals.ts`](../../src/renderer/src/store/slices/terminals.ts#L74)); +- the worktree slice imports parked-terminal cleanup + ([`src/renderer/src/store/slices/worktrees.ts`](../../src/renderer/src/store/slices/worktrees.ts#L42)). + +That graph is inherited by all three renderer entries. This is a stronger +reason to partition than selector CPU: the synthetic selector benchmark found +only 0.0148 ms per unrelated write for 2,500 subscribers. + +### Target domains + +Use a small shell coordinator over independently importable stores: + +1. **Durable shell:** appearance, keybindings, active route, window state. +2. **Workspace catalog:** repos, projects, worktrees, runtime ownership. +3. **Session:** tabs, editor/browser descriptors, active surface. +4. **Terminal runtime:** layouts, PTY bindings, stream metadata, parking. +5. **Agent live state:** status, unread/completion state, high-frequency clocks. +6. **Repository state:** projections from the repository-intelligence service. +7. **Integrations:** provider caches and feature-specific mutations. + +The shell may know stable identifiers and narrow interfaces. It must not import +implementation modules from terminal, onboarding, editor workers, browser, or +provider domains. + +Avoid a flag day. Introduce domain facades, mirror a slice into its new store, +move readers, then make the old slice a compatibility adapter until no imports +remain. Preserve reference gates and the existing listener census. + +### Success gates + +- Main, popout, and web entries import only stores they render. +- A terminal output/status update does not execute selectors in settings, + provider caches, or workspace catalog. +- Session persistence and runtime graph publication subscribe to explicit + domain projections. +- Existing no-op reference behavior and crash-diagnostic collection-size + reporting remain covered. + +## P1 — Mobile terminal hot set and cold parking + +### Evidence + +The mobile session maps every terminal record to `TerminalPaneView` +([`mobile/app/h/[hostId]/session/[worktreeId].tsx`](../../mobile/app/h/%5BhostId%5D/session/%5BworktreeId%5D.tsx#L4726)). +Each pane contains a `TerminalWebView`. Inactive panes deliberately remain +mounted and are hidden with `opacity: 0` to preserve xterm state +([`mobile/src/session/TerminalPaneView.tsx`](../../mobile/src/session/TerminalPaneView.tsx#L61)). + +Mobile already subscribes only to the active terminal and batches WebView +writes at roughly 20 Hz. The remaining scaling cost is retained WebView/xterm +instances, layout state, JavaScript heaps, textures, and native WebView +resources for every terminal in the session. + +Desktop has already demonstrated the safer model: bounded hot retention, +headless authority, byte watching, cold parking, and replay on reveal. + +### Target architecture + +- Keep the active terminal and, initially, the two most recently used terminals + warm. +- Retain terminal identity, unread state, modes, and replay cursor without a + WebView. +- On park, release the WebView after a grace period and record the last applied + stream sequence. +- On reveal, create one WebView, request an authoritative snapshot plus replay + after that sequence, apply theme/scale, fit, then subscribe live. +- If continuity cannot be proven, request a full snapshot rather than exposing + a partially replayed terminal. + +### Success gates + +- Native/JS/GPU memory is approximately bounded as terminal count grows. +- Ten- and fifty-terminal fixtures keep no more than the configured hot set. +- Warm switch remains visually immediate; cold reveal gets an explicit p95 + budget on iOS and Android. +- Split panes, selection, OSC/query replies, SSH/runtime terminals, background + reconnect, and Take Back remain correct. + +## P2 — Revisioned, replayable mobile workspace catalog + +### Evidence + +Mobile subscribes to `runtime.clientEvents.subscribe` but also performs a full +`worktree.ps` refresh every three seconds while foregrounded +([`mobile/src/worktree/host-worktree-refresh.ts`](../../mobile/src/worktree/host-worktree-refresh.ts#L5)). +The comments document the reason: events are not queued while disconnected, +and some desktop repo edits have not reached the runtime stream +([`mobile/src/worktree/host-worktree-refresh.ts`](../../mobile/src/worktree/host-worktree-refresh.ts#L42)). + +For 200 repos and 5,000 worktrees, the existing no-op equality gate is already +effective: five polls spent about 5.84 ms building sections and 3.96 ms +comparing equality, versus 27.95 ms in the older unconditional rebuild path. +The remaining opportunity is to avoid the request and snapshot work, especially +over relay/SSH paths. + +### Target architecture + +Publish a host catalog with a monotonically increasing revision: + +- `catalog.get({ afterRevision })` returns no change, a delta, or a snapshot; +- `catalog.subscribe({ fromRevision })` replays retained deltas, then emits + live events; +- reconnect detects gaps and requests one snapshot; +- repo metadata and worktree topology share a transaction boundary or declare + independent revisions. + +Once replay/gap recovery is proven, move the full foreground poll from three +seconds to a much slower safety interval. Keep foreground refresh and +reconnection reconciliation. + +This stream can later carry repository-intelligence revisions without copying +full Git status into every catalog event. + +## P2 — Partitioned durable persistence + +### Evidence + +Persistence is already substantially optimized: + +- writes are async, debounced, and serialized; +- mutation bursts have a bounded max wait; +- byte-identical state skips disk writes; +- secrets are normalized so randomized encryption does not defeat the hash; +- durable rename/fsync and backups protect against corruption; +- active-view and GitHub cache paths are narrower; +- renderer session payload construction is patch/reference gated. + +The remaining whole-document work happens before async I/O: +`buildStateToSave()` synchronously clones the durable state, encrypts secret +slots, `JSON.stringify`s the full document, performs substitutions, and hashes +it on the main thread +([`src/main/persistence.ts`](../../src/main/persistence.ts#L3624)). Shutdown uses a +synchronous durable write +([`src/main/persistence.ts`](../../src/main/persistence.ts#L3732)). + +The synthetic 1,000-tab profile was only 516 KB and loaded cheaply, so this is +not a P0/P1 finding on current evidence. + +### Trigger and target design + +Before implementation, capture real large-profile metrics: + +- serialized bytes by top-level domain; +- stringify/hash duration; +- async fsync/rename duration; +- shutdown flush duration; +- save frequency and no-op rate; +- renderer session-payload build duration. + +Escalate when p95 main-thread serialization exceeds a visible-jank budget or +shutdown flush becomes a reliability problem. Then partition by ownership: + +- settings/profile; +- workspace topology; +- session/tab layout; +- automation/history; +- provider caches; +- per-host runtime state. + +Use versioned envelopes and an atomic manifest or small journal so a partial +generation can be recovered. Preserve secret handling, backup rotation, and +cross-platform rename/fsync behavior. + +## Quick wins that support the architecture + +### Enforce static-entry budgets in CI + +Parse `out/renderer/.vite/manifest.json` after production builds and report +static raw/gzip bytes, chunk count, and top contributors for each HTML entry. +Also budget `out/main/index.js` and preload. Fail only on ratcheted regression +limits at first; tighten after the capability split. + +### Repair false dynamic boundaries + +Generate a build-time list of modules that are both statically and dynamically +imported. Start with the SFTP and SSH relay modules currently reached both ways: + +- `ssh-filesystem-provider-sftp.ts`; +- `sftp-upload.ts`; +- `ssh-relay-deploy-helpers.ts`. + +Move shared types/constants into small leaf modules, and make eager call sites +load implementations through a capability factory. Verify packaged ASAR paths +on all desktop platforms. + +### Add parse/evaluation attribution + +Current startup milestones begin after the main bundle is already executing. +Add build banner timings and capability-factory timings so Electron boot, +bundle parse/link, top-level evaluation, service construction, and async +initialization can be compared separately. A trace mode should remain +diagnostic-only because require tracing changes startup behavior. + +## Existing strengths — do not redo these + +The review found many areas where the obvious recommendation is already +implemented: + +- **Desktop terminals:** daemon ownership, main headless history, hidden-tab + cold parking, hot retention, parked byte watchers, WebGL disposal, reveal and + resource benchmarks. +- **Large UI collections:** worktree sidebar, source-control files, diffs, + reviews, search, AI Vault, and other heavy lists use virtualization where it + matters. +- **Git refresh mechanics:** event/file/push signals, visibility gates, + safety polling, slow-task backoff, huge-repo limits, worktree scan + coalescing, and several narrow caches. +- **File watching:** Parcel watcher isolation prevents native watcher faults + from taking down the main process and includes SSH/WSL-specific behavior. +- **Plugin runtime:** discovery is separated from worker activation; workers + are bounded and idle-reaped. +- **AI Vault:** scans share one cache owner, incremental parse state and + persisted parse caches exist, and OpenCode SQLite work is isolated in a + worker entry. +- **Browser panes:** inactive worktree guests are parked unless + automation-visible; diagnostics account for guest/parked state. +- **Mobile terminal delivery:** active-only subscription and batched WebView + writes already address stream CPU. +- **Mobile workspace rendering:** equality-gated publication avoids the former + unconditional rebuild cost. +- **Persistence:** async durable writes, no-op hashing, sidecars, patch-based + session updates, and shutdown safety are all meaningful improvements. +- **Diagnostics:** startup, main-thread jank, idle CPU, terminal scale/parking, + memory, workspace switch, and selector-fanout harnesses provide unusually + good foundations for performance work. + +Generic recommendations such as “virtualize the sidebar,” “unmount every +inactive desktop terminal,” “poll Git less,” or “move plugin workers out of the +main process” would duplicate existing work and risk regressions. + +## Suggested delivery roadmap + +### Phase 0 — measurement and guardrails + +- Commit static-entry reports and ratcheted budgets. +- Add startup parse/evaluation and first-commit milestones. +- Add subprocess/RPC counters to the large-repo and slow-SSH benchmarks. +- Add mobile WebView count and per-process memory diagnostics. + +### Phase 1 — dependency boundaries + +- Extract a minimal main kernel and post-paint capability registry. +- Introduce shell/workspace/session renderer stores. +- Remove terminal and onboarding dependencies from non-terminal entries. +- Repair the SFTP/SSH false dynamic boundaries. + +### Phase 2 — repository authority + +- Add the host-scoped snapshot service. +- Migrate active status and Checks first. +- Migrate remaining desktop consumers, then runtime/mobile projections. +- Preserve mutation fences and add Git 2.25 real-binary coverage. + +### Phase 3 — retained-resource scaling + +- Ship mobile terminal hot retention behind a flag. +- Measure memory and reveal p95 on both mobile platforms. +- Add revisioned catalog replay, then relax three-second polling. + +### Phase 4 — evidence-triggered persistence work + +- Profile large real states. +- Partition only the domains that breach serialization or shutdown budgets. + +## Coverage and methodology + +This was an architectural performance review rather than a line-by-line +correctness audit. It combined: + +- repository-wide production-file, synchronous-I/O, interval, worker, watcher, + and process scans; +- main startup and service-wiring inspection; +- renderer entry, Zustand, React, IPC, and large-list inspection; +- Git status, worktree, upstream, provider, SSH/WSL/runtime flow tracing; +- desktop and mobile terminal lifecycle inspection; +- persistence and session-hydration inspection; +- browser, AI Vault, plugin, automation, resource, and background-service + inspection; +- preload, shared RPC/runtime, CLI, mobile transport, and build configuration + review; +- a production build plus startup, restored-state, selector-fanout, and mobile + workspace-refresh benchmarks; +- review of the existing renderer memory profile and performance harnesses. + +Measured facts are labeled with byte counts or benchmark results. Proposed +impacts are qualitative unless an A/B implementation exists. The review did +not use production telemetry, a Windows/Linux build host, a physical mobile +device, or a controlled slow-SSH lab, so rollout gates explicitly require +those environments. + +## Benchmark commands + +```bash +pnpm run build:electron-vite + +node tools/benchmarks/startup-time-bench.mjs \ + --label big-picture-audit \ + --iterations 3 \ + --files 0 \ + --state-profile none \ + --linger-ms 300 \ + --timeout-ms 120000 + +node tools/benchmarks/startup-time-bench.mjs \ + --label big-picture-restored-200 \ + --iterations 3 \ + --files 0 \ + --state-profile restored-local-tabs \ + --session-tabs 200 \ + --linger-ms 300 \ + --timeout-ms 120000 + +node tools/benchmarks/startup-time-bench.mjs \ + --label big-picture-restored-1000 \ + --iterations 3 \ + --files 0 \ + --state-profile restored-local-tabs \ + --session-tabs 1000 \ + --linger-ms 300 \ + --timeout-ms 120000 + +pnpm bench:zustand-selector-fanout +pnpm --dir mobile repro:workspace-picker-lag +``` + +Generated build output and local benchmark JSON files are ignored by Git. The +material measurements are reproduced in this report so it remains +self-contained. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7388211487a..85199bacd57 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, type UserConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' +import { createStartupDiagnosticsBootstrapPlugin } from './build-plugins/startup-diagnostics-bootstrap' import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize']) @@ -50,137 +51,6 @@ const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL = ? JSON.stringify(orcaDiagnosticsTokenUrl) : 'null' -function createStartupDiagnosticsBanner(chunkName: string): string { - return ` -;(() => { - const env = typeof process !== 'undefined' ? process.env : undefined - const mode = env?.ORCA_STARTUP_DIAGNOSTICS - if (mode !== '1' && mode !== 'trace') { - return - } - const safeJson = (value) => { - try { - return JSON.stringify(value) - } catch { - return '""' - } - } - let closeSync - let diagnosticFileDescriptor - let openSync - let writeSync - try { - const fs = require('node:fs') - closeSync = fs.closeSync - openSync = fs.openSync - writeSync = fs.writeSync - } catch { - closeSync = undefined - openSync = undefined - writeSync = undefined - } - const diagnosticFile = env?.ORCA_STARTUP_DIAGNOSTICS_FILE - if (typeof diagnosticFile === 'string' && diagnosticFile.length > 0 && typeof openSync === 'function') { - try { - diagnosticFileDescriptor = openSync(diagnosticFile, 'a', 0o600) - } catch { - diagnosticFileDescriptor = undefined - } - } - const writeLine = (message) => { - try { - const line = message.endsWith('\\n') ? message : message + '\\n' - if (typeof writeSync === 'function') { - writeSync(2, line) - if (typeof diagnosticFileDescriptor === 'number') { - writeSync(diagnosticFileDescriptor, line) - } - } - } catch { - // Diagnostics must never affect startup. - } - } - const chunkName = ${JSON.stringify(chunkName)} - writeLine('[bootstrap] bundle-enter chunk=' + safeJson(chunkName) + ' pid=' + process.pid + ' ppid=' + process.ppid + ' execPath=' + safeJson(process.execPath) + ' argv=' + safeJson(process.argv) + ' electronRunAsNode=' + safeJson(env?.ELECTRON_RUN_AS_NODE ?? null)) - if (!globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__) { - globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__ = true - process.once('exit', (code) => { - writeLine('[bootstrap] process-exit code=' + code) - if (typeof closeSync === 'function' && typeof diagnosticFileDescriptor === 'number') { - try { - closeSync(diagnosticFileDescriptor) - } catch { - // Diagnostics must never affect shutdown. - } - } - }) - process.on('uncaughtExceptionMonitor', (error, origin) => { - const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error - writeLine('[bootstrap] uncaught-exception origin=' + safeJson(origin) + ' error=' + safeJson(String(message))) - }) - process.on('unhandledRejection', (reason) => { - const message = reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason - writeLine('[bootstrap] unhandled-rejection error=' + safeJson(String(message))) - }) - } - if (mode === 'trace' && !globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__) { - globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__ = true - try { - const Module = require('node:module') - const originalLoad = Module._load - const parsedTraceLimit = Number(env?.ORCA_STARTUP_DIAGNOSTICS_TRACE_LIMIT ?? 20000) - const traceLimit = Number.isFinite(parsedTraceLimit) && parsedTraceLimit > 0 ? parsedTraceLimit : 20000 - let traceLineCount = 0 - let traceLimitReported = false - const writeTraceLine = (message) => { - if (traceLineCount >= traceLimit) { - if (!traceLimitReported) { - traceLimitReported = true - writeLine('[bootstrap] require-trace-limit-reached limit=' + safeJson(traceLimit)) - } - return - } - traceLineCount += 1 - writeLine(message) - } - Module._load = function (request, parent, isMain) { - const parentName = parent && parent.filename ? parent.filename : null - writeTraceLine('[bootstrap] require-start request=' + safeJson(request) + ' parent=' + safeJson(parentName) + ' isMain=' + safeJson(Boolean(isMain))) - try { - const result = Reflect.apply(originalLoad, this, arguments) - writeTraceLine('[bootstrap] require-ok request=' + safeJson(request)) - return result - } catch (error) { - const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error - writeTraceLine('[bootstrap] require-error request=' + safeJson(request) + ' error=' + safeJson(String(message))) - throw error - } - } - } catch (error) { - writeLine('[bootstrap] require-trace-install-error error=' + safeJson(String(error))) - } - } -})(); -` -} - -function createStartupDiagnosticsBootstrapPlugin() { - return { - name: 'orca-startup-diagnostics-bootstrap', - generateBundle(_options, bundle) { - const mainChunk = bundle['index.js'] - if (!mainChunk || mainChunk.type !== 'chunk') { - return - } - - // Why: source-level startup diagnostics run after Rollup's generated - // prelude and require() list. Mutate the final emitted chunk so macOS - // launch failures can identify the earliest JS boundary reached. - mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code - } - } -} - export const electronViteConfig: UserConfig = { main: { build: { diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7e4ac17933f..dd2d108385b 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1783,6 +1783,7 @@ export default function SessionScreen() { terminalTabs, terminalsRef.current ) + const terminalRecordCount = mergedTerminalsForActive.length terminalsRef.current = mergedTerminalsForActive setTerminals((prev) => terminalRecordsEqual(prev, mergedTerminalsForActive) ? prev : mergedTerminalsForActive @@ -1858,7 +1859,7 @@ export default function SessionScreen() { pendingActiveTerminalHandleRef.current = null } } - diagnostics.tabsApplied(result, nextTabs, active, selectionSource) + diagnostics.tabsApplied(result, nextTabs, terminalRecordCount, active, selectionSource) activeSessionTabTypeRef.current = active?.type ?? null activeSessionTabIdRef.current = active?.id ?? null setActiveSessionTabId(active?.id ?? null) @@ -2934,7 +2935,6 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.delete(handle) } }, []) - const handleTerminalWebReady = useCallback( (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) @@ -4737,6 +4737,8 @@ export default function SessionScreen() { void saveTerminalTextScale(scale) }} onRef={setTerminalWebViewRef} + diagnostics={terminalDiagnosticsRef.current} + terminalRecordsLoaded={terminalsLoaded} onWebReady={handleTerminalWebReady} onSelectionMode={handleSelectionMode} onSelectionCopy={handleSelectionCopy} diff --git a/mobile/package.json b/mobile/package.json index 1041b422a34..edaa3ab3ed4 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@noble/hashes": "1.8.0", + "@orca/expo-process-memory": "file:./packages/expo-process-memory", "@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio", "@react-native-async-storage/async-storage": "^2.2.0", "@xterm/addon-unicode11": "0.10.0-beta.285", diff --git a/mobile/packages/expo-process-memory/android/build.gradle b/mobile/packages/expo-process-memory/android/build.gradle new file mode 100644 index 00000000000..50b6b17ce24 --- /dev/null +++ b/mobile/packages/expo-process-memory/android/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.processmemory' +version = '0.0.1' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() + +android { + namespace "expo.modules.processmemory" +} diff --git a/mobile/packages/expo-process-memory/android/src/main/AndroidManifest.xml b/mobile/packages/expo-process-memory/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..bdae66c8f5a --- /dev/null +++ b/mobile/packages/expo-process-memory/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/mobile/packages/expo-process-memory/android/src/main/java/expo/modules/processmemory/ExpoProcessMemoryModule.kt b/mobile/packages/expo-process-memory/android/src/main/java/expo/modules/processmemory/ExpoProcessMemoryModule.kt new file mode 100644 index 00000000000..8af3f15a532 --- /dev/null +++ b/mobile/packages/expo-process-memory/android/src/main/java/expo/modules/processmemory/ExpoProcessMemoryModule.kt @@ -0,0 +1,27 @@ +package expo.modules.processmemory + +import android.os.Debug +import android.os.Process +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoProcessMemoryModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoProcessMemory") + + Function("getProcessMemory") { + val memoryInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memoryInfo) + val totalPssKibibytes = memoryInfo.totalPss + require(totalPssKibibytes >= 0) { "Process memory is unavailable" } + mapOf( + "metric" to "proportional-set-size", + "value" to totalPssKibibytes.toDouble(), + "unit" to "kibibytes", + "processRole" to "app", + "pid" to Process.myPid(), + "sampledAtMs" to System.currentTimeMillis().toDouble() + ) + } + } +} diff --git a/mobile/packages/expo-process-memory/expo-module.config.json b/mobile/packages/expo-process-memory/expo-module.config.json new file mode 100644 index 00000000000..bb5f59e26ce --- /dev/null +++ b/mobile/packages/expo-process-memory/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["ExpoProcessMemoryModule"] + }, + "android": { + "modules": ["expo.modules.processmemory.ExpoProcessMemoryModule"] + } +} diff --git a/mobile/packages/expo-process-memory/ios/ExpoProcessMemory.podspec b/mobile/packages/expo-process-memory/ios/ExpoProcessMemory.podspec new file mode 100644 index 00000000000..4d7a40ee313 --- /dev/null +++ b/mobile/packages/expo-process-memory/ios/ExpoProcessMemory.podspec @@ -0,0 +1,19 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ExpoProcessMemory' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = package['license'] + s.author = 'Orca contributors' + s.homepage = 'https://github.com/stablyai/orca' + s.platforms = { :ios => '13.4' } + s.swift_version = '5.4' + s.source = { git: 'https://github.com/stablyai/orca.git' } + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.{h,m,swift}' +end diff --git a/mobile/packages/expo-process-memory/ios/ExpoProcessMemoryModule.swift b/mobile/packages/expo-process-memory/ios/ExpoProcessMemoryModule.swift new file mode 100644 index 00000000000..57c15965263 --- /dev/null +++ b/mobile/packages/expo-process-memory/ios/ExpoProcessMemoryModule.swift @@ -0,0 +1,32 @@ +import Darwin +import ExpoModulesCore +import Foundation + +public class ExpoProcessMemoryModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoProcessMemory") + + Function("getProcessMemory") { () throws -> [String: Any] in + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let status = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + guard status == KERN_SUCCESS else { + throw NSError(domain: "ExpoProcessMemory", code: Int(status)) + } + return [ + "metric": "physical-footprint", + "value": Double(info.phys_footprint), + "unit": "bytes", + "processRole": "app", + "pid": ProcessInfo.processInfo.processIdentifier, + "sampledAtMs": Date().timeIntervalSince1970 * 1_000 + ] + } + } +} diff --git a/mobile/packages/expo-process-memory/package.json b/mobile/packages/expo-process-memory/package.json new file mode 100644 index 00000000000..bb2126bd941 --- /dev/null +++ b/mobile/packages/expo-process-memory/package.json @@ -0,0 +1,17 @@ +{ + "name": "@orca/expo-process-memory", + "version": "0.0.1", + "private": true, + "description": "Native app-process memory diagnostics for Orca mobile", + "license": "MIT", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "pnpm --dir ../.. exec tsc --noEmit" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/mobile/packages/expo-process-memory/src/ExpoProcessMemoryModule.ts b/mobile/packages/expo-process-memory/src/ExpoProcessMemoryModule.ts new file mode 100644 index 00000000000..7292307d226 --- /dev/null +++ b/mobile/packages/expo-process-memory/src/ExpoProcessMemoryModule.ts @@ -0,0 +1,8 @@ +import { requireNativeModule } from 'expo-modules-core' +import type { NativeProcessMemoryMeasurement } from './process-memory-measurement' + +type ExpoProcessMemoryModule = { + getProcessMemory(): NativeProcessMemoryMeasurement +} + +export default requireNativeModule('ExpoProcessMemory') diff --git a/mobile/packages/expo-process-memory/src/index.ts b/mobile/packages/expo-process-memory/src/index.ts new file mode 100644 index 00000000000..eb5c5a384c2 --- /dev/null +++ b/mobile/packages/expo-process-memory/src/index.ts @@ -0,0 +1,2 @@ +export { default } from './ExpoProcessMemoryModule' +export type { NativeProcessMemoryMeasurement } from './process-memory-measurement' diff --git a/mobile/packages/expo-process-memory/src/process-memory-measurement.ts b/mobile/packages/expo-process-memory/src/process-memory-measurement.ts new file mode 100644 index 00000000000..fe5e62c5e81 --- /dev/null +++ b/mobile/packages/expo-process-memory/src/process-memory-measurement.ts @@ -0,0 +1,8 @@ +export type NativeProcessMemoryMeasurement = Readonly<{ + metric: 'physical-footprint' | 'proportional-set-size' + value: number + unit: 'bytes' | 'kibibytes' + processRole: 'app' + pid: number + sampledAtMs: number +}> diff --git a/mobile/packages/expo-process-memory/tsconfig.json b/mobile/packages/expo-process-memory/tsconfig.json new file mode 100644 index 00000000000..cdc890f0d76 --- /dev/null +++ b/mobile/packages/expo-process-memory/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo-module-scripts/tsconfig.base", + "compilerOptions": { + "outDir": "./build", + "module": "esnext", + "moduleResolution": "bundler" + }, + "include": ["./src"] +} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 9788413f925..0b9ba5e68f5 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@noble/hashes': specifier: 1.8.0 version: 1.8.0 + '@orca/expo-process-memory': + specifier: file:./packages/expo-process-memory + version: file:packages/expo-process-memory(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -1894,6 +1897,13 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@orca/expo-process-memory@file:packages/expo-process-memory': + resolution: {directory: packages/expo-process-memory, type: directory} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio': resolution: {directory: packages/expo-two-way-audio, type: directory} peerDependencies: @@ -8859,6 +8869,12 @@ snapshots: '@noble/hashes@1.8.0': {} + '@orca/expo-process-memory@file:packages/expo-process-memory(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) diff --git a/mobile/src/diagnostics/mobile-process-memory-diagnostics.test.ts b/mobile/src/diagnostics/mobile-process-memory-diagnostics.test.ts new file mode 100644 index 00000000000..93a852ed4a5 --- /dev/null +++ b/mobile/src/diagnostics/mobile-process-memory-diagnostics.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { + captureMobileProcessMemorySnapshot, + normalizeNativeProcessMemoryBytes, + type NativeProcessMemoryMeasurement +} from './mobile-process-memory-diagnostics' + +const IOS_MEASUREMENT: NativeProcessMemoryMeasurement = { + metric: 'physical-footprint', + value: 12_345, + unit: 'bytes', + processRole: 'app', + pid: 41, + sampledAtMs: 1_722_000_000_000 +} + +const ANDROID_MEASUREMENT: NativeProcessMemoryMeasurement = { + metric: 'proportional-set-size', + value: 2_048, + unit: 'kibibytes', + processRole: 'app', + pid: 42, + sampledAtMs: 1_722_000_000_001 +} + +describe('mobile process-memory diagnostics', () => { + it('normalizes native byte and KiB units to bytes', () => { + expect(normalizeNativeProcessMemoryBytes(IOS_MEASUREMENT)).toBe(12_345) + expect(normalizeNativeProcessMemoryBytes(ANDROID_MEASUREMENT)).toBe(2_097_152) + }) + + it('rejects invalid native byte values', () => { + expect(() => + normalizeNativeProcessMemoryBytes({ ...IOS_MEASUREMENT, value: Number.NaN }) + ).toThrow() + expect(() => normalizeNativeProcessMemoryBytes({ ...IOS_MEASUREMENT, value: -1 })).toThrow() + }) + + it.each([ + ['ios', IOS_MEASUREMENT, 12_345, 'physical-footprint'], + ['android', ANDROID_MEASUREMENT, 2_097_152, 'proportional-set-size'] + ] as const)( + 'attributes a supported %s app-process sample', + async (platform, raw, bytes, metric) => { + const snapshot = await captureMobileProcessMemorySnapshot(platform, () => raw) + + expect(snapshot).toMatchObject({ + platform, + supportStatus: 'supported', + processRole: 'app', + pid: raw.pid, + metric, + bytes, + byteUnit: 'bytes', + sampledAtMs: raw.sampledAtMs, + webContentProcessAttribution: 'unsupported-unattributed', + limitation: 'public-sandbox-api-unavailable', + errorKind: null + }) + } + ) + + it('reports unsupported platforms without calling native code', async () => { + const readNative = vi.fn() + + const snapshot = await captureMobileProcessMemorySnapshot('web', readNative, () => 77) + + expect(readNative).not.toHaveBeenCalled() + expect(snapshot).toMatchObject({ + platform: 'other', + supportStatus: 'unsupported', + bytes: null, + sampledAtMs: 77 + }) + }) + + it('turns native failures into a bounded error without copying messages', async () => { + const snapshot = await captureMobileProcessMemorySnapshot( + 'ios', + () => { + throw new Error('/private/worktree credential failed') + }, + () => 88 + ) + + expect(snapshot).toMatchObject({ + supportStatus: 'error', + bytes: null, + sampledAtMs: 88, + errorKind: 'native-error' + }) + expect(JSON.stringify(snapshot)).not.toContain('credential') + }) + + it('rejects a native metric attributed to the wrong platform', async () => { + const snapshot = await captureMobileProcessMemorySnapshot('ios', () => ANDROID_MEASUREMENT) + + expect(snapshot.supportStatus).toBe('error') + expect(snapshot.bytes).toBeNull() + }) + + it('uses a fixed privacy-safe schema', async () => { + const snapshot = await captureMobileProcessMemorySnapshot('ios', () => IOS_MEASUREMENT) + + expect(Object.keys(snapshot).sort()).toEqual( + [ + 'byteUnit', + 'bytes', + 'errorKind', + 'limitation', + 'metric', + 'pid', + 'platform', + 'processRole', + 'sampledAtMs', + 'supportStatus', + 'webContentProcessAttribution' + ].sort() + ) + }) +}) diff --git a/mobile/src/diagnostics/mobile-process-memory-diagnostics.ts b/mobile/src/diagnostics/mobile-process-memory-diagnostics.ts new file mode 100644 index 00000000000..ff21f2ee90d --- /dev/null +++ b/mobile/src/diagnostics/mobile-process-memory-diagnostics.ts @@ -0,0 +1,107 @@ +import type { NativeProcessMemoryMeasurement } from '@orca/expo-process-memory' + +export type { NativeProcessMemoryMeasurement } from '@orca/expo-process-memory' + +export type MobileProcessMemoryPlatform = 'ios' | 'android' | 'other' + +export type MobileProcessMemorySnapshot = Readonly<{ + platform: MobileProcessMemoryPlatform + supportStatus: 'supported' | 'unsupported' | 'error' + processRole: 'app' + pid: number | null + metric: NativeProcessMemoryMeasurement['metric'] | null + bytes: number | null + byteUnit: 'bytes' + sampledAtMs: number + webContentProcessAttribution: 'unsupported-unattributed' + limitation: 'public-sandbox-api-unavailable' + errorKind: 'native-error' | null +}> + +type NativeMemoryReader = () => + | NativeProcessMemoryMeasurement + | Promise + +const BYTE_MULTIPLIER: Record = { + bytes: 1, + kibibytes: 1024 +} + +export function normalizeNativeProcessMemoryBytes( + measurement: NativeProcessMemoryMeasurement +): number { + const bytes = measurement.value * BYTE_MULTIPLIER[measurement.unit] + if (!Number.isFinite(bytes) || bytes < 0 || !Number.isSafeInteger(Math.round(bytes))) { + throw new Error('Invalid native process-memory measurement') + } + return Math.round(bytes) +} + +export async function captureMobileProcessMemorySnapshot( + platform: string, + readNative: NativeMemoryReader, + now: () => number = Date.now +): Promise { + const normalizedPlatform: MobileProcessMemoryPlatform = + platform === 'ios' || platform === 'android' ? platform : 'other' + const base = { + platform: normalizedPlatform, + processRole: 'app' as const, + byteUnit: 'bytes' as const, + webContentProcessAttribution: 'unsupported-unattributed' as const, + limitation: 'public-sandbox-api-unavailable' as const + } + if (normalizedPlatform === 'other') { + return { + ...base, + supportStatus: 'unsupported', + pid: null, + metric: null, + bytes: null, + sampledAtMs: now(), + errorKind: null + } + } + try { + const measurement = await readNative() + const expectedMetric = + normalizedPlatform === 'ios' ? 'physical-footprint' : 'proportional-set-size' + const expectedUnit = normalizedPlatform === 'ios' ? 'bytes' : 'kibibytes' + if ( + measurement.processRole !== 'app' || + measurement.metric !== expectedMetric || + measurement.unit !== expectedUnit || + !Number.isFinite(measurement.sampledAtMs) + ) { + throw new Error('Invalid native process-memory attribution') + } + return { + ...base, + supportStatus: 'supported', + pid: Number.isSafeInteger(measurement.pid) && measurement.pid > 0 ? measurement.pid : null, + metric: measurement.metric, + bytes: normalizeNativeProcessMemoryBytes(measurement), + sampledAtMs: measurement.sampledAtMs, + errorKind: null + } + } catch { + return { + ...base, + supportStatus: 'error', + pid: null, + metric: null, + bytes: null, + sampledAtMs: now(), + errorKind: 'native-error' + } + } +} + +export async function readMobileProcessMemorySnapshot( + platform: string +): Promise { + return captureMobileProcessMemorySnapshot(platform, async () => { + const { default: nativeModule } = await import('@orca/expo-process-memory') + return nativeModule.getProcessMemory() + }) +} diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index 06174cd0378..5111adfa09e 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -1,6 +1,7 @@ -import { useCallback } from 'react' -import { StyleSheet, View } from 'react-native' +import { useCallback, useEffect, useRef } from 'react' +import { Platform, StyleSheet, View } from 'react-native' import { TerminalWebView } from '../terminal/TerminalWebView' +import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' import type { MobileTerminalTheme, TerminalKeyboardAvoidanceMetrics, @@ -15,6 +16,8 @@ type TerminalPaneViewProps = { terminalTheme?: MobileTerminalTheme textScale: number onRef: (handle: string, ref: TerminalWebViewHandle | null) => void + diagnostics: MobileTerminalDiagnostics + terminalRecordsLoaded: boolean onWebReady: (handle: string) => void onSelectionMode: (handle: string, active: boolean) => void onSelectionCopy: (handle: string, text: string) => void @@ -37,6 +40,8 @@ export function TerminalPaneView({ terminalTheme, textScale, onRef, + diagnostics, + terminalRecordsLoaded, onWebReady, onSelectionMode, onSelectionCopy, @@ -51,6 +56,12 @@ export function TerminalPaneView({ onOpenUrl, onTextScaleChange }: TerminalPaneViewProps) { + const diagnosticsMountLifecycleRef = useRef< + ReturnType | undefined + >(undefined) + if (diagnosticsMountLifecycleRef.current === undefined) { + diagnosticsMountLifecycleRef.current = diagnostics.createWebViewMountLifecycle() + } const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { onRef(handle, ref) @@ -58,6 +69,28 @@ export function TerminalPaneView({ [handle, onRef] ) + useEffect(() => { + const lifecycle = diagnosticsMountLifecycleRef.current + if (!lifecycle) { + return + } + lifecycle.mounted(active) + return lifecycle.unmounted + }, [diagnostics]) + + useEffect(() => { + const lifecycle = diagnosticsMountLifecycleRef.current + if (lifecycle) { + lifecycle.activityChanged(active) + if (terminalRecordsLoaded) { + void diagnostics.sampleProcessMemoryOnce(Platform.OS, { + terminalRecordsLoaded, + renderedTerminalPaneCount: 1 + }) + } + } + }, [active, diagnostics, terminalRecordsLoaded]) + return ( { expect(log).toHaveBeenCalledTimes(2) log.mockRestore() }) + + it('waits for a rendered terminal pane, then samples once across effect replay', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const readProcessMemory = vi.fn(async () => ({ + platform: 'ios' as const, + supportStatus: 'supported' as const, + processRole: 'app' as const, + pid: 41, + metric: 'physical-footprint' as const, + bytes: 12_345, + byteUnit: 'bytes' as const, + sampledAtMs: 77, + webContentProcessAttribution: 'unsupported-unattributed' as const, + limitation: 'public-sandbox-api-unavailable' as const, + errorKind: null + })) + const diagnostics = new MobileTerminalDiagnostics(true, readProcessMemory) + + await diagnostics.sampleProcessMemoryOnce('ios', { + terminalRecordsLoaded: false, + renderedTerminalPaneCount: 1 + }) + await diagnostics.sampleProcessMemoryOnce('ios', { + terminalRecordsLoaded: true, + renderedTerminalPaneCount: 0 + }) + await Promise.all( + Array.from({ length: 2 }, () => + diagnostics.sampleProcessMemoryOnce('ios', { + terminalRecordsLoaded: true, + renderedTerminalPaneCount: 1 + }) + ) + ) + + expect(readProcessMemory).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledOnce() + log.mockRestore() + }) + + it('does not load native process-memory diagnostics while disabled', async () => { + const readProcessMemory = vi.fn() + const diagnostics = new MobileTerminalDiagnostics(false, readProcessMemory) + + await diagnostics.sampleProcessMemoryOnce('ios', { + terminalRecordsLoaded: true, + renderedTerminalPaneCount: 1 + }) + + expect(readProcessMemory).not.toHaveBeenCalled() + }) + + it('keeps actual terminal records distinct from terminal and total tab counts', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostics = new MobileTerminalDiagnostics(true) + const tabs = [ + { id: 'ready', type: 'terminal', terminal: 'pty-1', isActive: true }, + { id: 'pending', type: 'terminal', terminal: null, isActive: false }, + { id: 'browser', type: 'browser', isActive: false } + ] + + diagnostics.tabsApplied({ snapshotVersion: 1, tabs }, tabs, 3, tabs[0], 'snapshot') + + expect(log).toHaveBeenCalledWith( + '[terminal-diagnostic]', + 'webview-count-snapshot', + expect.objectContaining({ + terminalRecordCount: 3, + terminalTabCount: 2, + tabCount: 3 + }) + ) + log.mockRestore() + }) }) diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts index 8695928ed03..90e7e3cbd8c 100644 --- a/mobile/src/session/mobile-terminal-diagnostics.ts +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -1,3 +1,10 @@ +import { + MobileTerminalProcessMemoryDiagnostics, + type MobileTerminalProcessMemoryReadiness, + type MobileTerminalProcessMemoryReader +} from './mobile-terminal-process-memory-diagnostics' +import { MobileTerminalWebViewCountDiagnostics } from './mobile-terminal-webview-count-diagnostics' + const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]' type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined @@ -37,13 +44,17 @@ export function getMobileTerminalDiagnosticErrorName(error: unknown): string { return typeof error } +export function isMobileTerminalDiagnosticsEnabled(): boolean { + return typeof __DEV__ === 'undefined' || __DEV__ +} + export function logMobileTerminalDiagnostic( event: string, details: MobileTerminalDiagnosticDetails = {} ): void { // Why: lifecycle diagnostics are intentionally available for HMR repros, // but high-frequency WebView events must not add production log overhead. - if (typeof __DEV__ !== 'undefined' && !__DEV__) { + if (!isMobileTerminalDiagnosticsEnabled()) { return } // Keep this structured and content-free so users can safely share a filtered log. @@ -57,6 +68,22 @@ export class MobileTerminalDiagnostics { private lastAppliedTabsSignature: string | null = null private lastTabsFetchStartAt = 0 private tabsFetchSkipLogged = false + private readonly processMemory: MobileTerminalProcessMemoryDiagnostics + private readonly webViewCounts: MobileTerminalWebViewCountDiagnostics + + constructor( + enabled = isMobileTerminalDiagnosticsEnabled(), + readProcessMemory?: MobileTerminalProcessMemoryReader + ) { + this.processMemory = new MobileTerminalProcessMemoryDiagnostics( + enabled, + (snapshot) => logMobileTerminalDiagnostic('process-memory-snapshot', snapshot), + readProcessMemory + ) + this.webViewCounts = new MobileTerminalWebViewCountDiagnostics(enabled, (snapshot) => { + logMobileTerminalDiagnostic('webview-count-snapshot', snapshot) + }) + } clearTerminalCache(): void { this.streamGateByHandle.clear() @@ -69,6 +96,7 @@ export class MobileTerminalDiagnostics { this.lastAppliedTabsSignature = null this.lastTabsFetchStartAt = 0 this.tabsFetchSkipLogged = false + this.webViewCounts.resetRoute() } terminalUnsubscribed(handle: string): void { @@ -171,9 +199,15 @@ export class MobileTerminalDiagnostics { tabsApplied( snapshot: DiagnosticTabsSnapshot, tabs: readonly DiagnosticTab[], + terminalRecordCount: number, activeTab: DiagnosticTab | null, selectionSource: string ): void { + this.webViewCounts.sessionSnapshot({ + terminalRecordCount, + terminalTabCount: tabs.filter((tab) => tab.type === 'terminal').length, + tabCount: tabs.length + }) const activeHandle = activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string' ? activeTab.terminal @@ -267,6 +301,17 @@ export class MobileTerminalDiagnostics { }) } + createWebViewMountLifecycle() { + return this.webViewCounts.createMountLifecycle() + } + + async sampleProcessMemoryOnce( + platform: string, + readiness: MobileTerminalProcessMemoryReadiness + ): Promise { + await this.processMemory.sampleOnce(platform, readiness) + } + private logTabs( event: 'tabs-applied' | 'tabs-fetch-success', snapshot: DiagnosticTabsSnapshot, diff --git a/mobile/src/session/mobile-terminal-process-memory-diagnostics.ts b/mobile/src/session/mobile-terminal-process-memory-diagnostics.ts new file mode 100644 index 00000000000..2b894ac78d7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-process-memory-diagnostics.ts @@ -0,0 +1,41 @@ +import { + readMobileProcessMemorySnapshot, + type MobileProcessMemorySnapshot +} from '../diagnostics/mobile-process-memory-diagnostics' + +export type MobileTerminalProcessMemoryReadiness = Readonly<{ + terminalRecordsLoaded: boolean + renderedTerminalPaneCount: number +}> + +export type MobileTerminalProcessMemoryReader = ( + platform: string +) => Promise + +type SnapshotEmitter = (snapshot: MobileProcessMemorySnapshot) => void + +export class MobileTerminalProcessMemoryDiagnostics { + private sampleStarted = false + + constructor( + private readonly enabled: boolean, + private readonly emit: SnapshotEmitter, + private readonly readProcessMemory: MobileTerminalProcessMemoryReader = readMobileProcessMemorySnapshot + ) {} + + async sampleOnce( + platform: string, + readiness: MobileTerminalProcessMemoryReadiness + ): Promise { + if ( + !this.enabled || + !readiness.terminalRecordsLoaded || + readiness.renderedTerminalPaneCount < 1 || + this.sampleStarted + ) { + return + } + this.sampleStarted = true + this.emit(await this.readProcessMemory(platform)) + } +} diff --git a/mobile/src/session/mobile-terminal-webview-count-diagnostics.test.ts b/mobile/src/session/mobile-terminal-webview-count-diagnostics.test.ts new file mode 100644 index 00000000000..6e3b29f8d86 --- /dev/null +++ b/mobile/src/session/mobile-terminal-webview-count-diagnostics.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MobileTerminalWebViewCountDiagnostics, + type MobileTerminalWebViewCountSnapshot +} from './mobile-terminal-webview-count-diagnostics' + +function createDiagnostics() { + const snapshots: MobileTerminalWebViewCountSnapshot[] = [] + return { + diagnostics: new MobileTerminalWebViewCountDiagnostics(true, (snapshot) => + snapshots.push(snapshot) + ), + snapshots + } +} + +describe('mobile terminal WebView count diagnostics', () => { + it.each([10, 50])('aggregates a %i-terminal retained set', (terminalCount) => { + const { diagnostics, snapshots } = createDiagnostics() + diagnostics.sessionSnapshot({ + terminalRecordCount: terminalCount, + terminalTabCount: terminalCount, + tabCount: terminalCount + 2 + }) + for (let index = 0; index < terminalCount; index += 1) { + diagnostics.createMountLifecycle()!.mounted(index === terminalCount - 1) + } + + expect(snapshots.at(-1)).toEqual({ + boundary: 'mount', + mountedWebViewCount: terminalCount, + activeMountedWebViewCount: 1, + inactiveMountedWebViewCount: terminalCount - 1, + terminalRecordCount: terminalCount, + terminalTabCount: terminalCount, + tabCount: terminalCount + 2 + }) + }) + + it('replays setup-cleanup-setup with one retained mount', () => { + const { diagnostics, snapshots } = createDiagnostics() + const lifecycle = diagnostics.createMountLifecycle()! + + lifecycle.mounted(true) + lifecycle.unmounted() + lifecycle.mounted(true) + lifecycle.mounted(true) + lifecycle.activityChanged(true) + + expect(snapshots.map((snapshot) => snapshot.boundary)).toEqual(['mount', 'unmount', 'mount']) + expect(snapshots.at(-1)?.mountedWebViewCount).toBe(1) + expect(diagnostics.retainedEntryCount()).toBe(1) + }) + + it('tracks active changes independently of mount state', () => { + const { diagnostics, snapshots } = createDiagnostics() + const firstLifecycle = diagnostics.createMountLifecycle()! + const secondLifecycle = diagnostics.createMountLifecycle()! + firstLifecycle.mounted(true) + secondLifecycle.mounted(false) + firstLifecycle.activityChanged(false) + secondLifecycle.activityChanged(true) + + expect(snapshots.at(-1)).toMatchObject({ + boundary: 'activity-change', + mountedWebViewCount: 2, + activeMountedWebViewCount: 1, + inactiveMountedWebViewCount: 1 + }) + }) + + it('clears mounted identities and totals at a route reset', () => { + const { diagnostics, snapshots } = createDiagnostics() + const lifecycle = diagnostics.createMountLifecycle()! + diagnostics.sessionSnapshot({ + terminalRecordCount: 2, + terminalTabCount: 3, + tabCount: 4 + }) + lifecycle.mounted(true) + + diagnostics.resetRoute() + lifecycle.unmounted() + + expect(diagnostics.retainedEntryCount()).toBe(0) + expect(snapshots.at(-1)).toEqual({ + boundary: 'route-reset', + mountedWebViewCount: 0, + activeMountedWebViewCount: 0, + inactiveMountedWebViewCount: 0, + terminalRecordCount: 0, + terminalTabCount: 0, + tabCount: 0 + }) + }) + + it('ignores a late old unmount after reset and a new same-handle pane mount', () => { + const { diagnostics, snapshots } = createDiagnostics() + const oldPaneLifecycle = diagnostics.createMountLifecycle()! + oldPaneLifecycle.mounted(true) + diagnostics.resetRoute() + + const newPaneLifecycle = diagnostics.createMountLifecycle()! + newPaneLifecycle.mounted(true) + const snapshotCount = snapshots.length + oldPaneLifecycle.unmounted() + + expect(snapshots).toHaveLength(snapshotCount) + expect(snapshots.at(-1)).toMatchObject({ + boundary: 'mount', + mountedWebViewCount: 1, + activeMountedWebViewCount: 1 + }) + expect(diagnostics.retainedEntryCount()).toBe(1) + }) + + it('allocates no retained entries or emissions while disabled', () => { + const emit = vi.fn() + const diagnostics = new MobileTerminalWebViewCountDiagnostics(false, emit) + + expect(diagnostics.createMountLifecycle()).toBeNull() + diagnostics.sessionSnapshot({ + terminalRecordCount: 1, + terminalTabCount: 1, + tabCount: 1 + }) + diagnostics.resetRoute() + + expect(diagnostics.retainedEntryCount()).toBe(0) + expect(emit).not.toHaveBeenCalled() + }) + + it('emits only bounded aggregate fields', () => { + const { diagnostics, snapshots } = createDiagnostics() + diagnostics.createMountLifecycle()!.mounted(true) + + expect(Object.keys(snapshots[0]).sort()).toEqual( + [ + 'activeMountedWebViewCount', + 'boundary', + 'inactiveMountedWebViewCount', + 'mountedWebViewCount', + 'tabCount', + 'terminalRecordCount', + 'terminalTabCount' + ].sort() + ) + }) +}) diff --git a/mobile/src/session/mobile-terminal-webview-count-diagnostics.ts b/mobile/src/session/mobile-terminal-webview-count-diagnostics.ts new file mode 100644 index 00000000000..82e6725a5a3 --- /dev/null +++ b/mobile/src/session/mobile-terminal-webview-count-diagnostics.ts @@ -0,0 +1,126 @@ +export type MobileTerminalSurfaceTotals = Readonly<{ + terminalRecordCount: number + terminalTabCount: number + tabCount: number +}> + +export type MobileTerminalWebViewCountSnapshot = MobileTerminalSurfaceTotals & + Readonly<{ + boundary: 'mount' | 'unmount' | 'activity-change' | 'session-snapshot' | 'route-reset' + mountedWebViewCount: number + activeMountedWebViewCount: number + inactiveMountedWebViewCount: number + }> + +type SnapshotEmitter = (snapshot: MobileTerminalWebViewCountSnapshot) => void + +declare const mobileTerminalWebViewMountIdentityBrand: unique symbol + +type MobileTerminalWebViewMountIdentity = Readonly<{ + [mobileTerminalWebViewMountIdentityBrand]: true +}> + +export type MobileTerminalWebViewMountLifecycle = Readonly<{ + mounted: (active: boolean) => void + activityChanged: (active: boolean) => void + unmounted: () => void +}> + +const EMPTY_TOTALS: MobileTerminalSurfaceTotals = { + terminalRecordCount: 0, + terminalTabCount: 0, + tabCount: 0 +} + +export class MobileTerminalWebViewCountDiagnostics { + private readonly mountedActivityByIdentity: Map< + MobileTerminalWebViewMountIdentity, + boolean + > | null + private totals = EMPTY_TOTALS + + constructor( + enabled: boolean, + private readonly emit: SnapshotEmitter + ) { + this.mountedActivityByIdentity = enabled ? new Map() : null + } + + private createMountIdentity(): MobileTerminalWebViewMountIdentity | null { + return this.mountedActivityByIdentity ? ({} as MobileTerminalWebViewMountIdentity) : null + } + + createMountLifecycle(): MobileTerminalWebViewMountLifecycle | null { + const identity = this.createMountIdentity() + return identity + ? { + mounted: (active) => this.mounted(identity, active), + activityChanged: (active) => this.activityChanged(identity, active), + unmounted: () => this.unmounted(identity) + } + : null + } + + private mounted(identity: MobileTerminalWebViewMountIdentity, active: boolean): void { + const mounted = this.mountedActivityByIdentity + if (!mounted || mounted.has(identity)) { + return + } + mounted.set(identity, active) + this.emitSnapshot('mount') + } + + private activityChanged(identity: MobileTerminalWebViewMountIdentity, active: boolean): void { + const mounted = this.mountedActivityByIdentity + if (!mounted || !mounted.has(identity) || mounted.get(identity) === active) { + return + } + mounted.set(identity, active) + this.emitSnapshot('activity-change') + } + + private unmounted(identity: MobileTerminalWebViewMountIdentity): void { + if (!this.mountedActivityByIdentity?.delete(identity)) { + return + } + this.emitSnapshot('unmount') + } + + sessionSnapshot(totals: MobileTerminalSurfaceTotals): void { + if ( + !this.mountedActivityByIdentity || + (this.totals.terminalRecordCount === totals.terminalRecordCount && + this.totals.terminalTabCount === totals.terminalTabCount && + this.totals.tabCount === totals.tabCount) + ) { + return + } + this.totals = totals + this.emitSnapshot('session-snapshot') + } + + resetRoute(): void { + if (!this.mountedActivityByIdentity) { + return + } + this.mountedActivityByIdentity.clear() + this.totals = EMPTY_TOTALS + this.emitSnapshot('route-reset') + } + + retainedEntryCount(): number { + return this.mountedActivityByIdentity?.size ?? 0 + } + + private emitSnapshot(boundary: MobileTerminalWebViewCountSnapshot['boundary']): void { + const activity = [...(this.mountedActivityByIdentity?.values() ?? [])] + const activeMountedWebViewCount = activity.filter(Boolean).length + this.emit({ + boundary, + mountedWebViewCount: activity.length, + activeMountedWebViewCount, + inactiveMountedWebViewCount: activity.length - activeMountedWebViewCount, + ...this.totals + }) + } +} diff --git a/package.json b/package.json index 1707fef5cb1..68a25640830 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", + "check:electron-bundle-budgets": "node config/scripts/electron-bundle-entry-report.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", diff --git a/src/main/browser/browser-kernel-window-dependencies.ts b/src/main/browser/browser-kernel-window-dependencies.ts new file mode 100644 index 00000000000..1d6db4e7b2e --- /dev/null +++ b/src/main/browser/browser-kernel-window-dependencies.ts @@ -0,0 +1,21 @@ +import type { BrowserManager } from './browser-manager' + +export type BrowserKernelWindowDependencies = { + browserManager: BrowserManager + isAllowedSessionPartition: (partition: string) => boolean +} + +let dependencies: BrowserKernelWindowDependencies | null = null + +export function setBrowserKernelWindowDependencies( + nextDependencies: BrowserKernelWindowDependencies +): void { + dependencies = nextDependencies +} + +export function getBrowserKernelWindowDependencies(): BrowserKernelWindowDependencies { + if (!dependencies) { + throw new Error('Browser kernel must be initialized before creating or attaching a window') + } + return dependencies +} diff --git a/src/main/claude-usage/claude-usage-file-path.test.ts b/src/main/claude-usage/claude-usage-file-path.test.ts new file mode 100644 index 00000000000..7c4b462c578 --- /dev/null +++ b/src/main/claude-usage/claude-usage-file-path.test.ts @@ -0,0 +1,35 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +describe('Claude usage file path', () => { + const earlyUserDataPath = join('early-root', 'profile') + const lateUserDataPath = join('late-root', 'profile') + + beforeEach(() => { + userDataPath = earlyUserDataPath + vi.resetModules() + }) + + it('keeps the path captured before the Electron app name changes', async () => { + const { getClaudeUsageFilePath, initClaudeUsagePath } = await import('./claude-usage-file-path') + + initClaudeUsagePath() + userDataPath = lateUserDataPath + + expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json')) + }) + + it('caches the fallback path when startup initialization was skipped', async () => { + const { getClaudeUsageFilePath } = await import('./claude-usage-file-path') + + expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json')) + userDataPath = lateUserDataPath + expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json')) + }) +}) diff --git a/src/main/claude-usage/claude-usage-file-path.ts b/src/main/claude-usage/claude-usage-file-path.ts new file mode 100644 index 00000000000..af98066a003 --- /dev/null +++ b/src/main/claude-usage/claude-usage-file-path.ts @@ -0,0 +1,16 @@ +import { app } from 'electron' +import { join } from 'node:path' + +let claudeUsageFilePath: string | null = null + +export function initClaudeUsagePath(): void { + // Why: app.setName can change userData casing, so capture the configured path before app readiness. + claudeUsageFilePath = join(app.getPath('userData'), 'orca-claude-usage.json') +} + +export function getClaudeUsageFilePath(): string { + if (!claudeUsageFilePath) { + claudeUsageFilePath = join(app.getPath('userData'), 'orca-claude-usage.json') + } + return claudeUsageFilePath +} diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 549758fc69f..70e4a7335a5 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ -import { app } from 'electron' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -18,6 +17,9 @@ import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getClaudeUsageFilePath } from './claude-usage-file-path' + +export { initClaudeUsagePath } from './claude-usage-file-path' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count @@ -26,10 +28,6 @@ const SCHEMA_VERSION = 5 const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 -// Why: capture the path after configureDevUserDataPath() but before app.setName() -// mutates Electron's derived userData location, matching the persistence/store pattern. -let _claudeUsageFile: string | null = null - type ClaudeModelPricing = { input: number output: number @@ -128,17 +126,6 @@ function getDefaultState(): ClaudeUsagePersistedState { } } -export function initClaudeUsagePath(): void { - _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') -} - -function getClaudeUsageFile(): string { - if (!_claudeUsageFile) { - _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') - } - return _claudeUsageFile -} - function hasClaudeModelVersion(model: string, family: string, version: string): boolean { const normalized = model.replace(/\./g, '-') return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized) @@ -339,7 +326,7 @@ export class ClaudeUsageStore { private load(): ClaudeUsagePersistedState { try { - const usageFile = getClaudeUsageFile() + const usageFile = getClaudeUsageFilePath() if (!existsSync(usageFile)) { return getDefaultState() } @@ -376,7 +363,7 @@ export class ClaudeUsageStore { } private writeToDisk(): void { - const usageFile = getClaudeUsageFile() + const usageFile = getClaudeUsageFilePath() const dir = dirname(usageFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) diff --git a/src/main/codex-usage/codex-usage-file-path.test.ts b/src/main/codex-usage/codex-usage-file-path.test.ts new file mode 100644 index 00000000000..1330b6a5306 --- /dev/null +++ b/src/main/codex-usage/codex-usage-file-path.test.ts @@ -0,0 +1,35 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +describe('Codex usage file path', () => { + const earlyUserDataPath = join('early-root', 'profile') + const lateUserDataPath = join('late-root', 'profile') + + beforeEach(() => { + userDataPath = earlyUserDataPath + vi.resetModules() + }) + + it('keeps the path captured before the Electron app name changes', async () => { + const { getCodexUsageFilePath, initCodexUsagePath } = await import('./codex-usage-file-path') + + initCodexUsagePath() + userDataPath = lateUserDataPath + + expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json')) + }) + + it('caches the fallback path when startup initialization was skipped', async () => { + const { getCodexUsageFilePath } = await import('./codex-usage-file-path') + + expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json')) + userDataPath = lateUserDataPath + expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json')) + }) +}) diff --git a/src/main/codex-usage/codex-usage-file-path.ts b/src/main/codex-usage/codex-usage-file-path.ts new file mode 100644 index 00000000000..1d999666b0c --- /dev/null +++ b/src/main/codex-usage/codex-usage-file-path.ts @@ -0,0 +1,16 @@ +import { app } from 'electron' +import { join } from 'node:path' + +let codexUsageFilePath: string | null = null + +export function initCodexUsagePath(): void { + // Why: app.setName can change userData casing, so capture the configured path before app readiness. + codexUsageFilePath = join(app.getPath('userData'), 'orca-codex-usage.json') +} + +export function getCodexUsageFilePath(): string { + if (!codexUsageFilePath) { + codexUsageFilePath = join(app.getPath('userData'), 'orca-codex-usage.json') + } + return codexUsageFilePath +} diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index f3c62fb8973..16902edd064 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ -import { app } from 'electron' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import type { CodexUsageBreakdownKind, @@ -18,6 +17,9 @@ import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' +import { getCodexUsageFilePath } from './codex-usage-file-path' + +export { initCodexUsagePath } from './codex-usage-file-path' // Why: v5 keys Codex ownership on raw token_count identity without session id // so forks that rewrite session_meta still match. Older caches used session- @@ -26,8 +28,6 @@ const SCHEMA_VERSION = 5 const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 -let _codexUsageFile: string | null = null - type TieredPrice = { threshold: number; price: number } type CodexModelPricing = { input: number @@ -161,17 +161,6 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU } } -export function initCodexUsagePath(): void { - _codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json') -} - -function getCodexUsageFile(): string { - if (!_codexUsageFile) { - _codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json') - } - return _codexUsageFile -} - function stripParenthesizedReasoningTier(model: string): string | null { const match = model.match(/^(.*)\(([^()]*)\)$/) if (!match) { @@ -374,7 +363,7 @@ export class CodexUsageStore { private load(): CodexUsagePersistedState { try { - const usageFile = getCodexUsageFile() + const usageFile = getCodexUsageFilePath() if (!existsSync(usageFile)) { return getDefaultState() } @@ -394,7 +383,7 @@ export class CodexUsageStore { } private writeToDisk(): void { - const usageFile = getCodexUsageFile() + const usageFile = getCodexUsageFilePath() const dir = dirname(usageFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) diff --git a/src/main/diagnostics/main-thread-churn-probe-report.test.ts b/src/main/diagnostics/main-thread-churn-probe-report.test.ts new file mode 100644 index 00000000000..603951bb1a9 --- /dev/null +++ b/src/main/diagnostics/main-thread-churn-probe-report.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { writeStartupDiagnosticLine } = vi.hoisted(() => ({ + writeStartupDiagnosticLine: vi.fn() +})) + +vi.mock('../startup/startup-diagnostics', () => ({ writeStartupDiagnosticLine })) + +import { + MAIN_THREAD_DIAGNOSTICS_ENV, + drainRemoteRpcRequestStats, + drainSubprocessSpawnStats, + recordRemoteRpcRequest, + recordSubprocessSpawn, + startMainThreadChurnProbe +} from './main-thread-churn-probe' + +function lastReport(): Record { + const line = writeStartupDiagnosticLine.mock.calls.at(-1)?.[0] + expect(line).toMatch(/^\[main-thread\] /) + return JSON.parse(line.slice('[main-thread] '.length)) +} + +describe('main-thread churn report windows', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllEnvs() + writeStartupDiagnosticLine.mockReset() + drainSubprocessSpawnStats() + drainRemoteRpcRequestStats() + }) + + it('drains subprocess and RPC counts into the same deterministic window', () => { + startMainThreadChurnProbe() + recordSubprocessSpawn('git', ['status'], 2) + recordRemoteRpcRequest('git.status') + recordRemoteRpcRequest('git.status') + + vi.advanceTimersByTime(5_000) + + expect(lastReport()).toMatchObject({ + spawnCount: 1, + spawns: { 'git status': { count: 1, blockMsTotal: 2, blockMsMax: 2 } }, + rpcCount: 2, + rpcs: { 'git.status': { count: 2 } } + }) + + vi.advanceTimersByTime(5_000) + + expect(lastReport()).toMatchObject({ + spawnCount: 0, + spawns: {}, + rpcCount: 0, + rpcs: {} + }) + }) + + it('does not schedule reporting while diagnostics are disabled', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '') + + startMainThreadChurnProbe() + vi.advanceTimersByTime(5_000) + + expect(vi.getTimerCount()).toBe(0) + expect(writeStartupDiagnosticLine).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts index cfc159fc1fb..92a48cb5eef 100644 --- a/src/main/diagnostics/main-thread-churn-probe.test.ts +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -1,15 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MAIN_THREAD_DIAGNOSTICS_ENV, + classifyRemoteRpcMethod, classifySubprocessCommand, + drainRemoteRpcRequestStats, drainSubprocessSpawnStats, isMainThreadDiagnosticsEnabled, + recordRemoteRpcRequest, recordSubprocessSpawn } from './main-thread-churn-probe' afterEach(() => { vi.unstubAllEnvs() drainSubprocessSpawnStats() + drainRemoteRpcRequestStats() }) describe('classifySubprocessCommand', () => { @@ -72,3 +76,30 @@ describe('recordSubprocessSpawn', () => { expect(drainSubprocessSpawnStats()).toEqual({}) }) }) + +describe('recordRemoteRpcRequest', () => { + it('keeps only stable protocol method buckets', () => { + expect(classifyRemoteRpcMethod('git.status')).toBe('git.status') + expect(classifyRemoteRpcMethod('fs.workspaceSpaceScan')).toBe('fs.workspaceSpaceScan') + expect(classifyRemoteRpcMethod('/home/alice/private-repo')).toBe('other') + expect(classifyRemoteRpcMethod(`git.${'x'.repeat(80)}`)).toBe('other') + }) + + it('is disabled without allocating retained stats', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '') + recordRemoteRpcRequest('git.status') + expect(drainRemoteRpcRequestStats()).toEqual({}) + }) + + it('aggregates once per method and drain resets the window', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + recordRemoteRpcRequest('git.status') + recordRemoteRpcRequest('git.status') + recordRemoteRpcRequest('git.history') + expect(drainRemoteRpcRequestStats()).toEqual({ + 'git.status': { count: 2 }, + 'git.history': { count: 1 } + }) + expect(drainRemoteRpcRequestStats()).toEqual({}) + }) +}) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts index f4fe900cf8c..a2cca87555d 100644 --- a/src/main/diagnostics/main-thread-churn-probe.ts +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -81,6 +81,8 @@ export type SubprocessSpawnStats = { } const spawnStatsByCommand = new Map() +export type RemoteRpcRequestStats = { count: number } +const rpcStatsByMethod = new Map() /** * Record one subprocess spawn from the main process. `blockMs` is how long @@ -119,6 +121,38 @@ export function drainSubprocessSpawnStats(): Record 80 || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+$/.test(method)) { + return 'other' + } + return method +} + +/** + * Record one outbound request at a remote host/provider RPC boundary. + * No-op unless ORCA_MAIN_THREAD_DIAGNOSTICS=1. + */ +export function recordRemoteRpcRequest(method: string): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + const key = classifyRemoteRpcMethod(method) + const stats = rpcStatsByMethod.get(key) + if (stats) { + stats.count++ + } else { + rpcStatsByMethod.set(key, { count: 1 }) + } +} + +export function drainRemoteRpcRequestStats(): Record { + const drained = Object.fromEntries( + [...rpcStatsByMethod].map(([key, stats]) => [key, { count: stats.count }]) + ) + rpcStatsByMethod.clear() + return drained +} + /** * Timestamped marker line for correlating a specific main-process activity * (e.g. an updater check) with the probe's stall windows and with macOS @@ -138,8 +172,8 @@ export function writeMainThreadDiagnosticMarker(marker: string): void { * Long-running main-process jank probe for benchmarks and field diagnosis of * issue #7576. Every 5s emits one `[main-thread] {json}` stderr line with the * window's worst event-loop stall, stall counts over 50/250ms, and drained - * subprocess spawn stats. Unlike the startup stall probe this never stops: - * the churn it measures (git status polling, updater retries) is steady-state. + * subprocess and remote RPC stats. Unlike the startup stall probe this never + * stops: the churn it measures is steady-state. */ export function startMainThreadChurnProbe(): void { if (!isMainThreadDiagnosticsEnabled()) { @@ -166,15 +200,20 @@ export function startMainThreadChurnProbe(): void { if (now - lastReport < REPORT_EVERY_MS) { return } + const windowDurationMs = now - lastReport lastReport = now const spawns = drainSubprocessSpawnStats() + const rpcs = drainRemoteRpcRequestStats() const report = { t: Math.round(now), + windowDurationMs: Math.round(windowDurationMs), maxGapMs: Math.max(0, Math.round(windowMaxGapMs)), gapsOver50Ms, gapsOver250Ms, spawnCount: Object.values(spawns).reduce((sum, s) => sum + s.count, 0), - spawns + spawns, + rpcCount: Object.values(rpcs).reduce((sum, rpc) => sum + rpc.count, 0), + rpcs } windowMaxGapMs = 0 gapsOver50Ms = 0 diff --git a/src/main/index.ts b/src/main/index.ts index 95c214cf886..c11d620785e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,86 +5,39 @@ import os from 'node:os' import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' +import type { Store } from './persistence' import { - Store, initDataPath, getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath -} from './persistence' +} from './persistence-data-path' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' import { applyAppIcon } from './app-icon' import { relaunchApp } from './app-relaunch' -import { StatsCollector, initStatsPath } from './stats/collector' -import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' -import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' -import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { - killAllPty, - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime -} from './ipc/pty' -import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' -import { closeAllWatchers } from './ipc/filesystem-watcher' -import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' -import { registerCoreHandlers } from './ipc/register-core-handlers' -import { initObservability, shutdownObservability } from './observability' -import { registerMobileHandlers } from './ipc/mobile' -import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' -import { classifyError } from './telemetry/classify-error' -import { runManagedHookInstallers } from './agent-hooks/install-telemetry' -import { - isAgentStatusHooksEnabled, - MANAGED_AGENT_HOOK_INSTALLERS, - removeManagedAgentHooks -} from './agent-hooks/managed-agent-hook-controls' -import { initCohortClassifier } from './telemetry/cohort-classifier' -import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' -import { resolveConsent } from './telemetry/consent' -import { triggerStartupNotificationRegistration } from './ipc/notifications' -import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' -import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' -import { - fingerprintOrchestrationPeer, - type OrchestrationEnvironmentTransport -} from './runtime/orchestration/environment-transport' -import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing' -import { resolveEnvironment } from '../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../shared/runtime-environments' -import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' +import type { StatsCollector } from './stats/collector' +import { initStatsPath } from './stats/stats-file-path' +import type { ClaudeUsageStore } from './claude-usage/store' +import { initClaudeUsagePath } from './claude-usage/claude-usage-file-path' +import type { CodexUsageStore } from './codex-usage/store' +import { initCodexUsagePath } from './codex-usage/codex-usage-file-path' +import type { OpenCodeUsageStore } from './opencode-usage/store' +import { initOpenCodeUsagePath } from './opencode-usage/opencode-usage-file-path' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' +import type { OrchestrationEnvironmentTransport } from './runtime/orchestration/environment-transport' +import type { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' import { recordRuntimeRpcStartFailure, showRuntimeRpcStartupFailureDialog } from './runtime/runtime-rpc-startup-failure' -import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' import { ServeReadinessPublisher } from './server/serve-readiness' import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' -import { DesktopRelayService } from './runtime/relay/desktop-relay-service' +import type { DesktopRelayService } from './runtime/relay/desktop-relay-service' import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker' -import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' -import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n' -import { - getNextDefaultOnAppearanceSettingValue, - registerAppMenu, - rebuildAppMenu -} from './menu/register-app-menu' -import { - checkForRemoteServerUpdate, - checkForUpdatesFromMenu, - downloadRemoteServerUpdate, - getRemoteServerUpdaterSnapshot, - installRemoteServerUpdate, - isQuittingForUpdate, - resolveUpdateInstallMode -} from './updater' -import { configureRemoteServerUpdater } from './runtime/remote-server-updater' +import { isQuittingForUpdate } from './updater-quit-state' import type { TuiAgent, UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { @@ -131,8 +84,63 @@ import { } from './startup/dev-education-suppression' import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' -import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' +import type { CoreIpcRegistry } from './startup/core-ipc-registry-startup-capability' +import { + getMainWindowStartupCapability, + installMainWindowStartupCapability +} from './startup/main-window-startup-owner' +import { + getTerminalRuntimeStartupCapability, + getTerminalRuntimeStartupCapabilityIfInstalled, + installTerminalRuntimeStartupCapability +} from './startup/terminal-runtime-startup-owner' +import { + getUpdaterRuntimeStartupCapability, + installUpdaterRuntimeStartupCapability +} from './startup/updater-runtime-startup-owner' +import { + getDesktopShellStartupCapability, + getDesktopShellStartupCapabilityIfInstalled, + installDesktopShellStartupCapability +} from './startup/desktop-shell-startup-owner' +import type { SystemTrayOptions } from './startup/desktop-shell-startup-capability' +import { + getAgentHookRuntimeStartupCapability, + getAgentHookRuntimeStartupCapabilityIfInstalled, + installAgentHookRuntimeStartupCapability +} from './startup/agent-hook-runtime-startup-owner' +import type { AgentHookProviderSessionIdentity } from './startup/agent-hook-runtime-startup-capability' +import { + getCodexLaunchSessionStartupCapability, + installCodexLaunchSessionStartupCapability +} from './startup/codex-launch-session-startup-owner' +import { + getTelemetryObservabilityStartupCapability, + getTelemetryObservabilityStartupCapabilityIfInstalled, + installTelemetryObservabilityStartupCapability +} from './startup/telemetry-observability-startup-owner' +import { + getCrashHangRuntimeStartupCapability, + installCrashHangRuntimeStartupCapability +} from './startup/crash-hang-runtime-startup-owner' +import { + getAccountRuntimeCoordinationStartupCapability, + installAccountRuntimeCoordinationStartupCapability +} from './startup/account-runtime-coordination-startup-owner' +import { + getRuntimeConnectivityStartupCapability, + installRuntimeConnectivityStartupCapability +} from './startup/runtime-connectivity-startup-owner' +import { + getShutdownWatcherRuntimeMetadataStartupCapability, + getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled, + installShutdownWatcherRuntimeMetadataStartupCapability +} from './startup/shutdown-watcher-runtime-metadata-startup-owner' +import type { + CrashReportStore, + ExpectedTeardownScope +} from './startup/crash-hang-runtime-startup-capability' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { @@ -152,114 +160,37 @@ import { import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' -import { RateLimitService } from './rate-limits/service' -import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' -import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' -import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' -import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' -import { - attachMainWindowServices, - ensureAutoUpdaterConfigured -} from './window/attach-main-window-services' -import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window' -import { - createSystemTray, - destroySystemTray, - setMacMenuBarIconVisible, - setTrayAttention, - type SystemTrayOptions -} from './tray/system-tray' -import { createMacAppActivationHandler } from './window/macos-app-activation' +import type { RateLimitService } from './rate-limits/service' import { focusExistingMainWindow } from './window/focus-existing-window' -import { notifyMainWindowBecameVisible } from './window/main-window-visibility' -import { CodexAccountService } from './codex-accounts/service' -import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' -import { markCodexProjectTrusted } from './agent-trust-presets' -import { - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './codex-accounts/runtime-selection' -import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' -import { - ensureRealHomeCodexHookState, - isRealHomeCodexHookLaneUsable -} from './codex/codex-real-home-hook-install' -import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' -import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' -import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' -import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' -import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' -import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' +import type { CodexAccountService } from './codex-accounts/service' +import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import type { CodexAccountSelectionTarget } from './codex-accounts/runtime-selection' import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' -import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' -import { ClaudeAccountService } from './claude-accounts/service' -import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './claude-accounts/live-pty-gate' -import { StarNagService } from './star-nag/service' -import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' -import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' -import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' -import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' +import type { ClaudeAccountService } from './claude-accounts/service' +import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' +import type { StarNagService } from './star-nag/service' import { moveWorktree } from './git/worktree' import { setDefaultWslDistroOverride } from './git/runner' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { parseWorkspaceKey } from '../shared/workspace-scope' -import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { AgentBrowserBridge } from './browser/agent-browser-bridge' -import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserCertificateTrustController, browserManager } from './browser/browser-manager' -import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' -import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' -import { setUnreadDockBadgeCount } from './dock/unread-badge' -import { AutomationService } from './automations/service' +import type { BrowserManager } from './browser/browser-manager' +import type { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' -import { AgentAwakeService } from './agent-awake-service' +import type { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline } from './quit-teardown-deadline' -import { PluginService } from './plugins/plugin-service' -import { PluginKillListService } from './plugins/plugin-kill-list-service' -import { getPluginsDataDir } from './plugins/plugin-discovery' -import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' -import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' -import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' +import type { PluginService } from './plugins/plugin-service' +import type { PluginKillListService } from './plugins/plugin-kill-list-service' +import type { PluginMarketplaceService } from './plugins/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' +import type { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' -import { - normalizePluginConsents, - normalizePluginIdList -} from '../shared/plugins/plugin-consent-state' -import { - recordCoalescedCrashBreadcrumb, - recordCrashBreadcrumb -} from './crash-reporting/crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog' -import { - consumeHangDetectionMarker, - hangDetectionMarkerPath -} from './hang-watchdog/hang-detection-marker' +import { recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity' -import { CrashReportStore } from './crash-reporting/crash-report-store' -import { - shouldRecoverRendererAfterProcessGone, - type ExpectedTeardownScope -} from './crash-reporting/process-gone-classification' -import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry @@ -278,13 +209,9 @@ import { HEADLESS_RUNTIME_WINDOW_ID, type RuntimeDesktopWindowStatus } from '../shared/runtime-types' -import { LocalPtyProvider } from './providers/local-pty-provider' -import { KeybindingService } from './keybindings/keybinding-service' +import type { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' -import { CliInstaller } from './cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' -import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ @@ -321,6 +248,9 @@ let pluginKillListService: PluginKillListService | null = null let pluginMarketplaceService: PluginMarketplaceService | null = null let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null let keybindings: KeybindingService | null = null +let browserManagerForShutdown: BrowserManager | null = null +let coreIpcRegistry: CoreIpcRegistry | null = null +let coreIpcRegistryLoadPromise: Promise | null = null function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { pluginService?.emitEvent( @@ -352,14 +282,42 @@ const isServeMode = process.argv.includes('--serve') if (isServeMode) { reserveServeStdoutForReadiness() } + +function loadCoreIpcRegistryForDesktop(): Promise { + if (coreIpcRegistry) { + return Promise.resolve(coreIpcRegistry) + } + coreIpcRegistryLoadPromise ??= import('./startup/core-ipc-registry-startup-capability').then( + ({ getCoreIpcRegistryStartupCapability }) => { + coreIpcRegistry = getCoreIpcRegistryStartupCapability() + return coreIpcRegistry + } + ) + return coreIpcRegistryLoadPromise +} + +function runDesktopActionWhenCoreIpcReady(action: () => void): void { + if (isQuittingForUpdate()) { + return + } + if (coreIpcRegistry) { + action() + return + } + void loadCoreIpcRegistryForDesktop() + .then(() => { + if (!isQuittingForUpdate()) { + action() + } + }) + .catch((error) => { + console.warn('[ipc] Failed to load the desktop core handler registry:', error) + }) +} + const desktopActivationGate = createServeDesktopActivationGate({ initialState: isServeMode ? 'initializing' : 'ready', - activateWindow: () => { - // Why: an updater replacement must not resurrect the old app bundle. - if (!isQuittingForUpdate()) { - focusExistingWindow() - } - }, + activateWindow: () => runDesktopActionWhenCoreIpcReady(focusExistingWindow), onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) }) // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. @@ -391,6 +349,11 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { payload: { state: string; prompt?: string; lastAssistantMessage?: string } isReplay: boolean | undefined }): void { + const { + maybeAutoRenameBranchOnFirstWork, + rememberBranchRenameFailureOutput, + renameWorktreeFolderOnFirstWork + } = getAgentHookRuntimeStartupCapability() const currentStore = store const currentRuntime = runtime if (!currentStore || !currentRuntime) { @@ -518,12 +481,6 @@ installUncaughtPipeErrorGuard() installUnhandledRejectionLogging() // Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. process.env.ORCA_APP_VERSION = app.getVersion() -configureRemoteServerUpdater({ - getSnapshot: getRemoteServerUpdaterSnapshot, - check: checkForRemoteServerUpdate, - download: downloadRemoteServerUpdate, - install: installRemoteServerUpdate -}) patchPackagedProcessPath() // Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. if (app.isPackaged && process.platform !== 'win32') { @@ -568,17 +525,13 @@ function requestDesktopActivation(): void { desktopActivationGate.requestActivation() } -const handleMacAppActivation = createMacAppActivationHandler({ - getWindow: () => mainWindow, - requestActivation: requestDesktopActivation -}) - function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const state = desktopActivationGate.getState() return state === 'ready' ? 'openable' : state } function settleServeDesktopActivation(): void { + const { getLocalPtyProvider, LocalPtyProvider } = getTerminalRuntimeStartupCapability() if (getLocalPtyProvider() instanceof LocalPtyProvider) { desktopActivationGate.markBlocked('persistent PTY provider unavailable') return @@ -647,7 +600,7 @@ function isRecoveryReloadInFlight(webContentsId: number): boolean { function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { // Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors. - recordCoalescedCrashBreadcrumb({ + getCrashHangRuntimeStartupCapability().recordCoalescedCrashBreadcrumb({ name: 'agent_state_changed', data: { agentType, state }, coalesceKey: `agent:${agentType}:${state}`, @@ -702,12 +655,11 @@ if (hasSingleInstanceLock) { appVersion: app.getVersion() }) initOrcaProfilePaths() - // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. + // Why: same timing as initDataPath — capture userData before app.setName changes it. initStatsPath() initClaudeUsagePath() initCodexUsagePath() initOpenCodeUsagePath() - crashReports = CrashReportStore.fromUserData() recordCrashBreadcrumb('app_started', { packaged: app.isPackaged, platform: process.platform, @@ -744,6 +696,10 @@ ipcMain.handle( ) function startTerminalRuntimeStartupServices(): Promise { + const { initDaemonPtyProvider, startFirstWindowStartupServices } = + getTerminalRuntimeStartupCapability() + const { agentHookServer, isAgentStatusHooksEnabled } = getAgentHookRuntimeStartupCapability() + const { classifyError, track } = getTelemetryObservabilityStartupCapability() logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. @@ -800,6 +756,9 @@ function prepareCodexRuntimeHomeForLaunch( launchEnv?: NodeJS.ProcessEnv, launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ): string | null { + const { isAgentStatusHooksEnabled } = getAgentHookRuntimeStartupCapability() + const { codexHookService, ensureRealHomeCodexHookState, markCodexProjectTrusted } = + getCodexLaunchSessionStartupCapability() if ( target?.runtime !== 'wsl' && launchContext?.launchAgent === 'codex' && @@ -888,6 +847,16 @@ async function prepareCodexSessionResumeForLaunch(args: { launchEnv?: NodeJS.ProcessEnv workspacePath?: string }): Promise { + const { isAgentStatusHooksEnabled } = getAgentHookRuntimeStartupCapability() + const { + codexHookService, + ensureRealHomeCodexHookState, + getOrcaManagedCodexHomePath, + getSystemCodexHomePath, + markCodexProjectTrusted, + prepareCodexSessionResume, + prepareLegacySharedCodexSessionResume + } = getCodexLaunchSessionStartupCapability() if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { return null } @@ -974,18 +943,20 @@ function showMainWindowFromTray(): void { mainWindow.focus() return } - if (!isQuittingForUpdate()) { - openMainWindow() - } + runDesktopActionWhenCoreIpcReady(openMainWindow) } function openSettingsFromSystemMenu(): void { + if ((!mainWindow || mainWindow.isDestroyed()) && !coreIpcRegistry) { + runDesktopActionWhenCoreIpcReady(openSettingsFromSystemMenu) + return + } showMainWindowFromTray() const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null if (!targetWindow) { return } - recordCrashBreadcrumb('settings_opened') + getCrashHangRuntimeStartupCapability().recordCrashBreadcrumb('settings_opened') // Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount. targetWindow.webContents.send('ui:openSettings') @@ -1005,8 +976,9 @@ function quitFromSystemTray(): void { // Why: menu/tray are clickable before anything else configures the updater. function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + const { ensureAutoUpdaterConfigured } = getMainWindowStartupCapability() ensureAutoUpdaterConfigured() - checkForUpdatesFromMenu(options) + getUpdaterRuntimeStartupCapability().checkForUpdatesFromMenu(options) } function getSystemTrayOptions(): SystemTrayOptions | null { @@ -1033,11 +1005,23 @@ function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { return null } const options = getSystemTrayOptions() - return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null + return options + ? getDesktopShellStartupCapability().setMacMenuBarIconVisible(showMenuBarIcon, options) + : null } function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') + const crashHangRuntime = getCrashHangRuntimeStartupCapability() + const { attachMainWindowServices, createMainWindow, loadMainWindow } = + getMainWindowStartupCapability() + const { createSystemTray, notifyMainWindowBecameVisible, setTrayAttention } = + getDesktopShellStartupCapability() + const { agentHookServer, setMigrationUnsupportedPtyListener } = + getAgentHookRuntimeStartupCapability() + const { prepareLegacySharedCodexSessionResume, resolveHostCodexSessionSourceHome } = + getCodexLaunchSessionStartupCapability() + const { resolveConsent, trackAppOpenedOnce } = getTelemetryObservabilityStartupCapability() if (!store) { throw new Error('Store must be initialized before opening the main window') } @@ -1079,6 +1063,10 @@ function openMainWindow(): BrowserWindow { if (!keybindings) { throw new Error('Keybinding service must be initialized before opening the main window') } + const registerCoreHandlers = coreIpcRegistry + if (!registerCoreHandlers) { + throw new Error('Core IPC registry must be loaded before opening the main window') + } // Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall). if (process.platform === 'win32') { @@ -1112,12 +1100,12 @@ function openMainWindow(): BrowserWindow { ) }, shouldRecoverRenderer: (details, webContentsId) => - shouldRecoverRendererAfterProcessGone({ + crashHangRuntime.shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: getExpectedTeardownScope(webContentsId) }), onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { - recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { + crashHangRuntime.recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, recentRecoveryCount @@ -1131,15 +1119,15 @@ function openMainWindow(): BrowserWindow { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + crashHangRuntime.recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, // Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787). onBeforeRecoveryReload: (webContentsId) => { markRecoveryReloadInFlight(webContentsId) - recordDurableCrashBreadcrumb('renderer_recovery_reload') + crashHangRuntime.recordDurableCrashBreadcrumb('renderer_recovery_reload') } }) - recordCrashBreadcrumb('main_window_created') + crashHangRuntime.recordCrashBreadcrumb('main_window_created') logStartupMilestone('window-created') // Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint. let trayCreated = false @@ -1171,7 +1159,7 @@ function openMainWindow(): BrowserWindow { const rendererWebContentsId = window.webContents.id const onFirstWindowLoad = (): void => { clearExpectedRendererReload(rendererWebContentsId) - recordCrashBreadcrumb('main_window_loaded') + crashHangRuntime.recordCrashBreadcrumb('main_window_loaded') logStartupMilestone('did-finish-load') if (!store) { return @@ -1243,13 +1231,13 @@ function openMainWindow(): BrowserWindow { if (window.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } - recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) + crashHangRuntime.recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) }, // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). isRecoveryReloadInFlight, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode), + updateInstallMode: getUpdaterRuntimeStartupCapability().resolveUpdateInstallMode(isServeMode), onWorktreeLifecycle: emitPluginWorktreeLifecycle } ) @@ -1414,7 +1402,10 @@ async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promi ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options) if (response === 0 && mainWindow && !mainWindow.isDestroyed()) { - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + getCrashHangRuntimeStartupCapability().recordDurableCrashBreadcrumb( + 'renderer_recovery_manual_retry' + ) + const { loadMainWindow } = getMainWindowStartupCapability() loadMainWindow(mainWindow) } else if (response === 1) { isQuitting = true @@ -1465,7 +1456,8 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro if (!result.shouldEngageFallback) { return } - recordCrashBreadcrumb('gpu_fallback_engaged', { + const crashHangRuntime = getCrashHangRuntimeStartupCapability() + crashHangRuntime.recordCrashBreadcrumb('gpu_fallback_engaged', { reason, exitCode, crashesInWindow: result.crashesInWindow @@ -1505,13 +1497,13 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro return } if (restartDecision !== 'restart') { - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) + crashHangRuntime.recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) return } isQuitting = true relaunchApp('gpu-fallback', fallbackData) // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() + getDesktopShellStartupCapability().destroySystemTray() app.exit(0) } @@ -1523,7 +1515,7 @@ function recordProcessGoneCrash( details: Record, webContentsId?: number ): void { - recordProcessGoneCrashEvent(crashReports, { + getCrashHangRuntimeStartupCapability().recordProcessGoneCrash(crashReports, { source, processType, reason, @@ -1534,14 +1526,18 @@ function recordProcessGoneCrash( } function shutdownWatchersOnce(): Promise { + const shutdown = getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled() + if (!shutdown) { + return Promise.resolve() + } if (watcherShutdownDone) { return Promise.resolve() } if (!watcherShutdownPromise) { // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. watcherShutdownPromise = Promise.allSettled([ - closeAllWatchers(), - disposeWorktreeBaseDirectoryWatchers() + shutdown.closeAllWatchers(), + shutdown.disposeWorktreeBaseDirectoryWatchers() ]) .then((results) => { for (const result of results) { @@ -1650,7 +1646,10 @@ async function printServeReady(options: ServeOptions): Promise { } const boundEndpoint = runtimeRpc.getWebSocketEndpoint() const advertised = boundEndpoint - ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + ? getRuntimeConnectivityStartupCapability().resolveAdvertisedPairingEndpoint( + boundEndpoint, + options.pairingAddress + ) : null const pairing = options.noPairing ? ({ @@ -1702,11 +1701,6 @@ function installServeSignalHandlers(): void { process.once('SIGTERM', quit) } -// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. -registerPaneKeyTeardownListener((paneKey) => { - stopSyntheticTitleSpinner(paneKey) -}) - function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { if (!mainWindow || mainWindow.isDestroyed()) { return @@ -1777,7 +1771,7 @@ function tickSyntheticTitleSpinners(): void { const ticks = advanceSyntheticTitleSpinnerEntries({ entries: syntheticTitleSpinnerByPaneKey, frameCount: SPINNER_FRAMES.length, - getPtyIdForPaneKey + getPtyIdForPaneKey: getTerminalRuntimeStartupCapability().getPtyIdForPaneKey }) for (const tick of ticks) { sendSyntheticTitle( @@ -1809,7 +1803,7 @@ function driveSyntheticTitleFromHook( state: AgentStatusState, profile: SyntheticAgentTitleProfile ): void { - const ptyId = getPtyIdForPaneKey(paneKey) + const ptyId = getTerminalRuntimeStartupCapability().getPtyIdForPaneKey(paneKey) if (!ptyId) { return } @@ -1865,12 +1859,75 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { void app.whenReady().then(async () => { logStartupMilestone('app-ready') - installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() }) - const hangDetection = consumeHangDetectionMarker( - hangDetectionMarkerPath(getCanonicalUserDataPath()) + const { createBrowserKernelStartupCapability } = + await import('./startup/browser-kernel-startup-capability') + const browserKernel = createBrowserKernelStartupCapability() + const { createMainWindowStartupCapability } = + await import('./startup/main-window-startup-capability') + installMainWindowStartupCapability(createMainWindowStartupCapability()) + const { createTerminalRuntimeStartupCapability } = + await import('./startup/terminal-runtime-startup-capability') + const terminalRuntime = createTerminalRuntimeStartupCapability() + installTerminalRuntimeStartupCapability(terminalRuntime) + // Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. + terminalRuntime.registerPaneKeyTeardownListener(stopSyntheticTitleSpinner) + const { createUpdaterRuntimeStartupCapability } = + await import('./startup/updater-runtime-startup-capability') + const updaterRuntime = createUpdaterRuntimeStartupCapability() + installUpdaterRuntimeStartupCapability(updaterRuntime) + updaterRuntime.configureRemoteServerUpdater({ + getSnapshot: updaterRuntime.getRemoteServerUpdaterSnapshot, + check: updaterRuntime.checkForRemoteServerUpdate, + download: updaterRuntime.downloadRemoteServerUpdate, + install: updaterRuntime.installRemoteServerUpdate + }) + const { createDesktopShellStartupCapability } = + await import('./startup/desktop-shell-startup-capability') + const desktopShell = createDesktopShellStartupCapability() + installDesktopShellStartupCapability(desktopShell) + const handleMacAppActivation = desktopShell.createMacAppActivationHandler({ + getWindow: () => mainWindow, + requestActivation: requestDesktopActivation + }) + const { createAgentHookRuntimeStartupCapability } = + await import('./startup/agent-hook-runtime-startup-capability') + const agentHooks = createAgentHookRuntimeStartupCapability() + installAgentHookRuntimeStartupCapability(agentHooks) + const { createCodexLaunchSessionStartupCapability } = + await import('./startup/codex-launch-session-startup-capability') + const codexLaunchSession = createCodexLaunchSessionStartupCapability() + installCodexLaunchSessionStartupCapability(codexLaunchSession) + const { createTelemetryObservabilityStartupCapability } = + await import('./startup/telemetry-observability-startup-capability') + const telemetryObservability = createTelemetryObservabilityStartupCapability() + installTelemetryObservabilityStartupCapability(telemetryObservability) + const { createCrashHangRuntimeStartupCapability } = + await import('./startup/crash-hang-runtime-startup-capability') + const crashHangRuntime = createCrashHangRuntimeStartupCapability() + installCrashHangRuntimeStartupCapability(crashHangRuntime) + const { createAccountRuntimeCoordinationStartupCapability } = + await import('./startup/account-runtime-coordination-startup-capability') + const accountRuntimeCoordination = createAccountRuntimeCoordinationStartupCapability() + installAccountRuntimeCoordinationStartupCapability(accountRuntimeCoordination) + const { createRuntimeConnectivityStartupCapability } = + await import('./startup/runtime-connectivity-startup-capability') + const runtimeConnectivity = createRuntimeConnectivityStartupCapability() + installRuntimeConnectivityStartupCapability(runtimeConnectivity) + const { createShutdownWatcherRuntimeMetadataStartupCapability } = + await import('./startup/shutdown-watcher-runtime-metadata-startup-capability') + const shutdownWatcherRuntimeMetadata = createShutdownWatcherRuntimeMetadataStartupCapability() + installShutdownWatcherRuntimeMetadataStartupCapability(shutdownWatcherRuntimeMetadata) + crashReports = crashHangRuntime.CrashReportStore.fromUserData() + const browserManager = browserKernel.browserManager + browserManagerForShutdown = browserManager + crashHangRuntime.installMainThreadHangWatchdog({ + userDataPath: getCanonicalUserDataPath() + }) + const hangDetection = crashHangRuntime.consumeHangDetectionMarker( + crashHangRuntime.hangDetectionMarkerPath(getCanonicalUserDataPath()) ) if (hangDetection) { - recordDurableCrashBreadcrumb('main_thread_hang_detected', { + crashHangRuntime.recordDurableCrashBreadcrumb('main_thread_hang_detected', { unresponsiveMs: hangDetection.unresponsiveMs, previousPid: hangDetection.parentPid, selfRecovered: hangDetection.selfRecovered @@ -1880,7 +1937,7 @@ void app.whenReady().then(async () => { app.on( 'certificate-error', (event, webContents, url, error, certificate, callback, isMainFrame) => { - browserCertificateTrustController.handleCertificateError({ + browserKernel.browserCertificateTrustController.handleCertificateError({ event, webContents, url, @@ -1894,14 +1951,17 @@ void app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) // Why: setName drives the macOS safeStorage Keychain item name; use the stable appName (not per-branch `name`) so dev branches share one key and don't re-prompt. app.setName(devInstanceIdentity.appName) + const { createCliWslStartupCapability } = await import('./startup/cli-wsl-startup-capability') + const cliWslStartupCapability = createCliWslStartupCapability() // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. managedWslCliReconciliationStatus = 'pending' - managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ - isPackaged: app.isPackaged, - userDataPath: getCanonicalUserDataPath(), - appVersion: app.getVersion() - }) + managedWslCliReconciliationReady = cliWslStartupCapability + .reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: getCanonicalUserDataPath(), + appVersion: app.getVersion() + }) .then((results) => { for (const result of results) { if (result.outcome === 'failed') { @@ -1926,7 +1986,8 @@ void app.whenReady().then(async () => { ) const activeOrcaProfile = ensureActiveOrcaProfile() - store = new Store({ dataFile: activeOrcaProfile.dataFile }) + const { createStoreStartupCapability } = await import('./startup/store-startup-capability') + store = createStoreStartupCapability({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) @@ -1941,15 +2002,15 @@ void app.whenReady().then(async () => { } }) // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) + accountRuntimeCoordination.attachClaudeLivePtyPersistence(store) // Why: while a live claude defers the managed OAuth refresh, usage shows // "Waiting for Claude session"; refetch when the last live PTY exits so the // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { + accountRuntimeCoordination.onLiveClaudePtysDrained(() => { void rateLimits?.refreshAfterClaudeLivePtysDrained() }) const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + accountRuntimeCoordination.seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) if (persistedClaudePtyIds.length > 0) { console.log( `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` @@ -1966,16 +2027,16 @@ void app.whenReady().then(async () => { console.warn('[proxy] Failed to apply network proxy settings') } // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. - initializeBrowserSessionsForApp({ + browserKernel.initializeBrowserSessionsForApp({ orcaProfileId: activeOrcaProfile.profile.id, profileDirectory: activeOrcaProfile.profileDirectory }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() - agentAwakeService = new AgentAwakeService() + const { createAgentAwakeStartupCapability } = + await import('./startup/agent-awake-startup-capability') + agentAwakeService = await createAgentAwakeStartupCapability() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) - // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. - agentAwakeService.setStatuses([]) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const collectChangedProviderSessionWorktrees = agentHooks.createHookProviderSessionInvalidator() const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { const ownedIdentities = identities.map((identity) => ({ ...identity, @@ -1988,27 +2049,26 @@ void app.whenReady().then(async () => { runtime?.notifyMobileSessionTabsChanged(worktreeId) } } - const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + const unsubscribeStatusChanges = agentHooks.agentHookServer.subscribeStatusChanges((statuses) => { agentAwakeService?.setStatuses(statuses) }) - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => { + const unsubscribeProviderSessionChanges = + agentHooks.agentHookServer.subscribeProviderSessionChanges((sessions) => { // Healthy session.tabs streams need a push when transcript identity changes. publishProviderSessionChanges(sessions) - } - ) + }) unsubscribeAgentAwakeStatusChanges = () => { unsubscribeStatusChanges() unsubscribeProviderSessionChanges() } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. - initTelemetry(store) + telemetryObservability.initTelemetry(store) // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not // a crash (the app is force-quit, so no report is ever generated). Without this the incidence // number the watchdog exists to produce would sit unread on the user's disk. Must run after // initTelemetry: track() drops silently until the client and store are wired. if (hangDetection) { - track('main_thread_hang_detected', { + telemetryObservability.track('main_thread_hang_detected', { unresponsive_ms: Math.round(hangDetection.unresponsiveMs), self_recovered: hangDetection.selfRecovered }) @@ -2016,76 +2076,101 @@ void app.whenReady().then(async () => { // Why: the trust-grant module is bundled into plain-node CLI entries where // the telemetry client cannot load, so the tracker is injected here instead // of imported there. - setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { - track('codex_trust_grant', { - outcome, - host_kind: hostKind, - lane, - ...(reason !== undefined ? { fallback_reason: reason } : {}), - ...(errorClass !== undefined ? { error_class: errorClass } : {}), - ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) - }) - }) + codexLaunchSession.setCodexTrustGrantTelemetry( + ({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { + telemetryObservability.track('codex_trust_grant', { + outcome, + host_kind: hostKind, + lane, + ...(reason !== undefined ? { fallback_reason: reason } : {}), + ...(errorClass !== undefined ? { error_class: errorClass } : {}), + ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + }) + } + ) // Why: the error-tracking lane (telemetry-error-tracking.md) is its own // composition root — independent of product telemetry — and must // initialize before any IPC handler / runtime span is created so the // tracer's active sink is populated at the moment the first span fires. // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED // / CI internally; those gates do not need to be re-checked here. - initObservability() - recordDurableCrashBreadcrumb('main_process_lifecycle_started', { + telemetryObservability.initObservability() + crashHangRuntime.recordDurableCrashBreadcrumb('main_process_lifecycle_started', { packaged: app.isPackaged, platform: process.platform }) // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). - initCohortClassifier(store) - initOnboardingCohortClassifier(store) - stats = new StatsCollector() - claudeUsage = new ClaudeUsageStore(store) - codexUsage = new CodexUsageStore(store) - openCodeUsage = new OpenCodeUsageStore(store) - rateLimits = new RateLimitService() - codexRuntimeHome = new CodexRuntimeHomeService(store) - // Why: an incapable trust-grant host must fall back to the managed home for - // every consumer (PTY env, rate limits, commit messages) in one place. - codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - codexRuntimeHome !== null && - codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(store?.getSettings()) - ) - const codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - isQuitting: () => isQuitting, - resolveSystemCodexHomePathOverride: () => - resolveHostCodexSessionSourceHome(store!.getSettings()), - startBackfill: startCodexSessionBackfillInBackground, - startIndexHeal: startCodexSessionIndexHealInBackground + telemetryObservability.initCohortClassifier(store) + telemetryObservability.initOnboardingCohortClassifier(store) + const { createStatsCollectorStartupCapability } = + await import('./startup/stats-collector-startup-capability') + stats = await createStatsCollectorStartupCapability() + const { createClaudeUsageStoreStartupCapability } = + await import('./startup/claude-usage-store-startup-capability') + claudeUsage = await createClaudeUsageStoreStartupCapability(store) + const { createCodexUsageStoreStartupCapability } = + await import('./startup/codex-usage-store-startup-capability') + codexUsage = await createCodexUsageStoreStartupCapability(store) + const { createOpenCodeUsageStoreStartupCapability } = + await import('./startup/opencode-usage-store-startup-capability') + openCodeUsage = await createOpenCodeUsageStoreStartupCapability(store) + const { createAccountServicesStartupCapability } = + await import('./startup/account-services-startup-capability') + const accountServices = createAccountServicesStartupCapability(store, { + configureCodexRuntimeHome: (runtimeHome) => { + codexRuntimeHome = runtimeHome + // Why: an incapable trust-grant host must fall back to the managed home for + // every consumer (PTY env, rate limits, commit messages) in one place. + runtimeHome.setRealHomeLaneGate(() => codexLaunchSession.isRealHomeCodexHookLaneUsable()) + // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy + // system-home sweep inside managed installs would delete the entry the + // real-home installer just appended. Flag OFF, hooks off, or an incapable + // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. + codexLaunchSession.setSystemCodexHomeHookSweepSuppressed( + () => + codexRuntimeHome !== null && + codexRuntimeHome.isHostSystemDefaultRealHome() && + agentHooks.isAgentStatusHooksEnabled(store?.getSettings()) + ) + const codexSessionMigration = codexLaunchSession.createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + isQuitting: () => isQuitting, + resolveSystemCodexHomePathOverride: () => + codexLaunchSession.resolveHostCodexSessionSourceHome(store!.getSettings()), + startBackfill: codexLaunchSession.startCodexSessionBackfillInBackground, + startIndexHeal: codexLaunchSession.startCodexSessionIndexHealInBackground + }) + return { + codexAccountLifecycle: { + onHostSystemDefaultSelected: codexSessionMigration.requestRun + }, + // Why: one-time per-host backfill makes historical Orca-managed Codex + // sessions visible to the user's own resume picker and app history (#4444, + // #8612). Deferred so startup and first PTY spawns never compete with the + // sessions tree walk. + afterCodexAccountCreated: codexSessionMigration.scheduleInitialRun + } + } }) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { - onHostSystemDefaultSelected: codexSessionMigration.requestRun - }) - // Why: one-time per-host backfill makes historical Orca-managed Codex - // sessions visible to the user's own resume picker and app history (#4444, - // #8612). Deferred so startup and first PTY spawns never compete with the - // sessions tree walk. - codexSessionMigration.scheduleInitialRun() - claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) - claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) + rateLimits = accountServices.rateLimits + codexRuntimeHome = accountServices.codexRuntimeHome + codexAccounts = accountServices.codexAccounts + claudeRuntimeAuth = accountServices.claudeRuntimeAuth + claudeAccounts = accountServices.claudeAccounts rateLimits.setCodexHomePathResolver((target) => codexRuntimeHome!.prepareForRateLimitFetch(target) ) - rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) - rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) - const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( - rateLimits, - store.getSettings() + rateLimits.setCodexFetchTarget( + accountRuntimeCoordination.getInitialCodexRateLimitTarget(store.getSettings()) ) + rateLimits.setClaudeFetchTarget( + accountRuntimeCoordination.getInitialClaudeRateLimitTarget(store.getSettings()) + ) + const syncAccountRuntimeTargets = + accountRuntimeCoordination.createAccountRuntimeTargetSettingsSync( + rateLimits, + store.getSettings() + ) store.onSettingsChanged((updates, settings) => { // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. void syncAccountRuntimeTargets(updates, settings).catch((error) => @@ -2096,7 +2181,7 @@ void app.whenReady().then(async () => { claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). - agentHookServer.setClaudeStatusLineListener((event) => { + agentHooks.agentHookServer.setClaudeStatusLineListener((event) => { rateLimits?.ingestLiveClaudeRateLimits(event) }) rateLimits.setOpenCodeGoConfigResolver(() => { @@ -2109,14 +2194,17 @@ void app.whenReady().then(async () => { rateLimits.setMiniMaxConfigResolver(() => { const settings = store!.getSettings() return { - sessionCookie: readMiniMaxSessionCookie() ?? '', + sessionCookie: + getAccountRuntimeCoordinationStartupCapability().readMiniMaxSessionCookie() ?? '', groupId: settings.minimaxGroupId, models: settings.minimaxUsageModels } }) rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) - keybindings = new KeybindingService({ + const { createKeybindingServiceStartupCapability } = + await import('./startup/keybinding-service-startup-capability') + keybindings = await createKeybindingServiceStartupCapability({ homePath: app.getPath('home'), getLegacyOverrides: () => store!.getSettings().keybindings, legacyTabSwitchSeed: { @@ -2131,8 +2219,12 @@ void app.whenReady().then(async () => { const settings = store!.getSettings() const activeIds = new Set( [ - normalizeClaudeRuntimeSelection(settings).host, - ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + getAccountRuntimeCoordinationStartupCapability().normalizeClaudeRuntimeSelection(settings) + .host, + ...Object.values( + getAccountRuntimeCoordinationStartupCapability().normalizeClaudeRuntimeSelection(settings) + .wsl + ) ].filter(Boolean) ) return settings.claudeManagedAccounts @@ -2149,8 +2241,8 @@ void app.whenReady().then(async () => { const settings = store!.getSettings() const activeIds = new Set( [ - normalizeCodexRuntimeSelection(settings).host, - ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + codexLaunchSession.normalizeCodexRuntimeSelection(settings).host, + ...Object.values(codexLaunchSession.normalizeCodexRuntimeSelection(settings).wsl) ].filter(Boolean) ) return settings.codexManagedAccounts @@ -2159,16 +2251,16 @@ void app.whenReady().then(async () => { }) const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { resolve: (selector) => { - const environment = resolveEnvironment(app.getPath('userData'), selector) - const pairing = getPreferredPairingOffer(environment) + const environment = runtimeConnectivity.resolveEnvironment(app.getPath('userData'), selector) + const pairing = runtimeConnectivity.getPreferredPairingOffer(environment) return { environmentId: environment.id, name: environment.name, - peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + peerFingerprint: runtimeConnectivity.fingerprintOrchestrationPeer(pairing.publicKeyB64) } }, call: (selector, method, params, timeoutMs, envelope) => - callRuntimeEnvironment( + runtimeConnectivity.callRuntimeEnvironment( app.getPath('userData'), selector, method, @@ -2178,18 +2270,20 @@ void app.whenReady().then(async () => { envelope ) } - const runtimeService = new OrcaRuntimeService(store, stats, { - agentSessionClaimSigner: loadAgentSessionClaimSigner( + const { createOrcaRuntimeServiceStartupCapability } = + await import('./startup/runtime-service-startup-capability') + const runtimeService = createOrcaRuntimeServiceStartupCapability(store, stats, { + agentSessionClaimSigner: runtimeConnectivity.loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() ), // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). - getLocalProvider: () => getLocalPtyProvider(), + getLocalProvider: () => terminalRuntime.getLocalPtyProvider(), // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. - getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - onPtyStopped: clearProviderPtyState, + getSshProvider: (connectionId) => terminalRuntime.getSshPtyProvider(connectionId), + onPtyStopped: terminalRuntime.clearProviderPtyState, onTerminalAgentStatus: (event) => { - agentHookServer.ingestTerminalStatus(event) + agentHooks.agentHookServer.ingestTerminalStatus(event) }, // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { @@ -2200,33 +2294,41 @@ void app.whenReady().then(async () => { getDesktopWindowStatus: getDesktopWindowStatus, // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + agentHooks.agentHookServer + .getStatusSnapshot() + .filter((entry) => entry.providerSessionOnly !== true), // Why: the filter above hides resume-identity rows from the live-agent views, but // those rows carry the provider session mobile native chat addresses transcripts // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionSnapshot: () => agentHooks.agentHookServer.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), + agentHooks.agentHookServer.getStatusSnapshotForPane(paneKey), // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], prepareAiVaultSessionResume: (args) => - prepareLegacySharedCodexSessionResume(args, { + codexLaunchSession.prepareLegacySharedCodexSessionResume(args, { isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, getSelectedHostAccountCodexHomePath: () => codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) + systemCodexHomePath: codexLaunchSession.resolveHostCodexSessionSourceHome( + store!.getSettings() + ) }), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + agentHooks.isAgentStatusHooksEnabled(store?.getSettings()) + ? agentHooks.agentHookServer.buildPtyEnv() + : {}, orchestrationEnvironmentTransport }) runtime = runtimeService - publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) + publishProviderSessionChanges(agentHooks.agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtimeService.notifyMobileSessionTabsChanged(worktreeId) }) - automations = new AutomationService(store, { + const { createAutomationServiceStartupCapability } = + await import('./startup/automation-service-startup-capability') + automations = await createAutomationServiceStartupCapability(store, { claudeUsage, codexUsage, // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. @@ -2326,14 +2428,29 @@ void app.whenReady().then(async () => { prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) }) const pluginSystemStartupStartedAt = performance.now() - pluginKillListService = new PluginKillListService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')) - }) - await pluginKillListService.initialize() - pluginMarketplaceService = new PluginMarketplaceService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null - }) + const { createPluginSystemStartupCapability } = + await import('./startup/plugin-system-startup-capability') + const pluginSystem = await createPluginSystemStartupCapability( + { + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged + }, + { + getSettings: () => store!.getSettings(), + getKeybindings: () => keybindings?.getOverrides() ?? {}, + getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, + getBlockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() + } + ) + pluginKillListService = pluginSystem.killList + pluginMarketplaceService = pluginSystem.marketplace + pluginMarketplaceInstaller = pluginSystem.marketplaceInstaller + pluginService = pluginSystem.pluginService + const bundledPluginBootstrap: PluginBundledBootstrapCoordinator = pluginSystem.bundledBootstrap const requestOfficialMarketplaceSeed = (): void => { if (store?.getSettings().pluginSystemEnabled !== true) { return @@ -2342,37 +2459,6 @@ void app.whenReady().then(async () => { console.warn('[plugins] failed to configure the official marketplace:', error) }) } - pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ - marketplace: pluginMarketplaceService, - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null - }) - pluginService = new PluginService({ - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Feature flag: with the setting off, discovery returns nothing and no - // plugin code path runs at all. - isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, - getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), - getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), - getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), - getKeybindings: () => keybindings?.getOverrides() ?? {}, - getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, - hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) - }) - const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ - root: resolveBundledPluginRoot({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }), - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - isEnabled: () => store?.getSettings().pluginSystemEnabled === true, - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, - refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() - }) const requestBundledPluginBootstrap = (): void => { void bundledPluginBootstrap .request() @@ -2433,7 +2519,9 @@ void app.whenReady().then(async () => { event.contentPacksChanged && setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) ) { - void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) + void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => + desktopShell.rebuildAppMenu() + ) } for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { @@ -2445,7 +2533,7 @@ void app.whenReady().then(async () => { requestOfficialMarketplaceSeed() // v0 plugin event seams: agent status (hook pipeline tap) + worktree // lifecycle (runtime tap). Server-side filtered per plugin subscription. - agentHookServer.subscribeEnrichedStatus((enriched) => { + agentHooks.agentHookServer.subscribeEnrichedStatus((enriched) => { pluginService?.emitEvent('agent.status.changed', { worktreeId: enriched.worktreeId ?? null, paneKey: enriched.paneKey, @@ -2456,34 +2544,30 @@ void app.whenReady().then(async () => { runtimeService.onWorktreeLifecycle((event) => { emitPluginWorktreeLifecycle(event) }) - starNag = new StarNagService(store, stats) - starNag.start() - starNag.registerIpcHandlers() - runtimeService.setAgentBrowserBridge( - new AgentBrowserBridge(browserManager, { - onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - ) + const { createStarNagStartupCapability } = await import('./startup/star-nag-startup-capability') + starNag = await createStarNagStartupCapability(store, stats) + const { attachAgentBrowserStartupCapability } = + await import('./startup/agent-browser-startup-capability') + attachAgentBrowserStartupCapability(runtimeService, browserManager) - // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. - const emulatorBridge = new EmulatorBridge() - runtimeService.setEmulatorBridge(emulatorBridge) + const { attachEmulatorStartupCapability } = await import('./startup/emulator-startup-capability') + attachEmulatorStartupCapability(runtimeService) nativeTheme.themeSource = store.getSettings().theme ?? 'system' if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { // Why: establish capability before managed-hook reconciliation so an // incapable host re-arms and completes the legacy real-home sweep now. - ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()), + codexLaunchSession.ensureRealHomeCodexHookState({ + hooksEnabled: agentHooks.isAgentStatusHooksEnabled(store.getSettings()), userDataPath: app.getPath('userData') }) } if (shouldInstallManagedHooks(is.dev)) { // Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch. - if (isAgentStatusHooksEnabled(store.getSettings())) { - runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS) + if (agentHooks.isAgentStatusHooksEnabled(store.getSettings())) { + agentHooks.runManagedHookInstallers(agentHooks.MANAGED_AGENT_HOOK_INSTALLERS) } else { - removeManagedAgentHooks() + agentHooks.removeManagedAgentHooks() } } app.on('child-process-gone', (_event, details) => { @@ -2508,28 +2592,28 @@ void app.whenReady().then(async () => { await setMainUiLanguage(store.getSettings().uiLanguage) logStartupMilestone('i18n-ready') - registerAppMenu({ + desktopShell.registerAppMenu({ appMenuLabel: devInstanceIdentity.name, onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + crashHangRuntime.recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, onOpenSettings: openSettingsFromSystemMenu, onOpenSetupGuide: (targetWindow) => { - recordCrashBreadcrumb('setup_guide_opened') + crashHangRuntime.recordCrashBreadcrumb('setup_guide_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null sendOpenSetupGuide(targetBrowserWindow) }, onOpenCrashReport: (targetWindow) => { - recordCrashBreadcrumb('crash_report_opened') + crashHangRuntime.recordCrashBreadcrumb('crash_report_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null sendOpenCrashReport(targetBrowserWindow) }, onOpenFeatureTour: (targetWindow) => { - recordCrashBreadcrumb('feature_tour_opened') + crashHangRuntime.recordCrashBreadcrumb('feature_tour_opened') // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null sendOpenFeatureTour(targetBrowserWindow) @@ -2537,17 +2621,17 @@ void app.whenReady().then(async () => { // Why: menu zoom must act on the window the user is looking at — routing to // the main window while the dashboard pop-out is focused zooms behind it. onZoomIn: () => { - if (!zoomDashboardPopoutIfFocused('in')) { + if (!desktopShell.zoomDashboardPopoutIfFocused('in')) { mainWindow?.webContents.send('terminal:zoom', 'in') } }, onZoomOut: () => { - if (!zoomDashboardPopoutIfFocused('out')) { + if (!desktopShell.zoomDashboardPopoutIfFocused('out')) { mainWindow?.webContents.send('terminal:zoom', 'out') } }, onZoomReset: () => { - if (!zoomDashboardPopoutIfFocused('reset')) { + if (!desktopShell.zoomDashboardPopoutIfFocused('reset')) { mainWindow?.webContents.send('terminal:zoom', 'reset') } }, @@ -2568,9 +2652,9 @@ void app.whenReady().then(async () => { } const current = store.getSettings() // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. - const next = getNextDefaultOnAppearanceSettingValue(current[key]) + const next = desktopShell.getNextDefaultOnAppearanceSettingValue(current[key]) store.updateSettings({ [key]: next }, { notifyListeners: true }) - rebuildAppMenu() + desktopShell.rebuildAppMenu() }, getAppearanceState: () => { const settings = store?.getSettings() @@ -2604,7 +2688,9 @@ void app.whenReady().then(async () => { } // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) - runtimeRpc = new OrcaRuntimeRpcServer({ + const { createOrcaRuntimeRpcServerStartupCapability } = + await import('./startup/runtime-rpc-server-startup-capability') + runtimeRpc = createOrcaRuntimeRpcServerStartupCapability({ runtime, // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. userDataPath: getCanonicalUserDataPath(), @@ -2620,7 +2706,7 @@ void app.whenReady().then(async () => { : {}), webClientRoot: getBundledWebClientRoot() }) - registerMobileHandlers(runtimeRpc, { + runtimeConnectivity.registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus, consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { if ( @@ -2656,7 +2742,7 @@ void app.whenReady().then(async () => { }) // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. await localPtyStartupReady - registerHeadlessPtyRuntime( + terminalRuntime.registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), @@ -2666,7 +2752,9 @@ void app.whenReady().then(async () => { ) // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager)) + const { attachOffscreenBrowserStartupCapability } = + await import('./startup/offscreen-browser-startup-capability') + attachOffscreenBrowserStartupCapability(runtime, browserManager) } // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) @@ -2680,11 +2768,11 @@ void app.whenReady().then(async () => { if (process.platform === 'darwin' || process.platform === 'linux') { try { // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ + const cliStatus = await cliWslStartupCapability.installServeCli({ privilegedRunner: async () => { throw new Error('serve CLI auto-install must not request administrator privileges') } - }).install() + }) console.log( `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` ) @@ -2698,7 +2786,7 @@ void app.whenReady().then(async () => { // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { try { - const dispatcher = await installLinuxBareOrcaDispatcher({ + const dispatcher = await cliWslStartupCapability.installLinuxBareOrcaDispatcher({ resourcesPath: process.resourcesPath }) console.log( @@ -2718,6 +2806,7 @@ void app.whenReady().then(async () => { return } + await loadCoreIpcRegistryForDesktop() // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. const [win, runtimeRpcStartResult] = await Promise.all([ Promise.resolve(openMainWindow()), @@ -2736,7 +2825,9 @@ void app.whenReady().then(async () => { const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { try { - const relayService = new DesktopRelayService({ + const { createDesktopRelayServiceStartupCapability } = + await import('./startup/desktop-relay-service-startup-capability') + const relayService = await createDesktopRelayServiceStartupCapability({ authConfig: cloudAuth.config, userDataPath: getProfileUserDataPath(), appVersion: app.getVersion(), @@ -2771,7 +2862,7 @@ void app.whenReady().then(async () => { } const onboarding = store.getOnboarding() if (onboarding.closedAt !== null) { - triggerStartupNotificationRegistration(store) + desktopShell.triggerStartupNotificationRegistration(store) } }) }) @@ -2801,6 +2892,10 @@ app.on('before-quit', () => { // Why: will-quit fires twice — first pass runs sync cleanup + preventDefault to await checkpoint writes; second pass exits. let daemonDisconnectDone = false app.on('will-quit', (e) => { + const terminalRuntime = getTerminalRuntimeStartupCapabilityIfInstalled() + const telemetryObservability = getTelemetryObservabilityStartupCapabilityIfInstalled() + const shutdownWatcherRuntimeMetadata = + getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled() // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() @@ -2812,7 +2907,7 @@ app.on('will-quit', (e) => { ) } // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. - destroySystemTray() + getDesktopShellStartupCapabilityIfInstalled()?.destroySystemTray() // Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()). starNag?.stop() automations?.stop() @@ -2826,18 +2921,19 @@ app.on('will-quit', (e) => { pluginMarketplaceInstaller = null const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() pluginService = null - setUnreadDockBadgeCount(0) - agentHookServer.stop() + getDesktopShellStartupCapabilityIfInstalled()?.setUnreadDockBadgeCount(0) + const agentHooks = getAgentHookRuntimeStartupCapabilityIfInstalled() + agentHooks?.agentHookServer.stop() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. - wslHookRelayManager.disposeAll() + agentHooks?.wslHookRelayManager.disposeAll() stats?.flush() // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. runtime?.getAgentBrowserBridge()?.destroyAllSessions() // Why: headless offscreen browser windows are main-process owned; tear them down explicitly on quit. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() - browserManager.setBrowserGuestStateChangedListener(null) + browserManagerForShutdown?.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - killAllPty() + terminalRuntime?.killAllPty() const watcherShutdown = shutdownWatchersOnce() store?.flush() @@ -2851,11 +2947,17 @@ app.on('will-quit', (e) => { const rpcStopAndClear = runtimeRpc ? runtimeRpc .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => + getShutdownWatcherRuntimeMetadataStartupCapability().awaitRuntimeFileWatcherUnsubscribes() + ) .then(() => { if (ownedRuntimeId) { // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + getShutdownWatcherRuntimeMetadataStartupCapability().clearRuntimeMetadataIfOwned( + getCanonicalUserDataPath(), + ownedPid, + ownedRuntimeId + ) } }) .catch((error) => { @@ -2865,23 +2967,30 @@ app.on('will-quit', (e) => { // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + const daemonTeardown = terminalRuntime + ? isDevParentShutdownRequested() + ? terminalRuntime.shutdownDaemon() + : terminalRuntime.disconnectDaemon() + : Promise.resolve() // Why: a wedged transport (half-open post-sleep socket) can leave one // member unsettled forever and block app.quit() until Force Quit (#9447). - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown } - ]) + const teardownSettlement = shutdownWatcherRuntimeMetadata + ? shutdownWatcherRuntimeMetadata.settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown } + ]) + : Promise.resolve([]) + teardownSettlement .then((pendingTeardowns) => { if (pendingTeardowns.length > 0) { console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) } }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) + .then(() => telemetryObservability?.shutdownTelemetry()) + .then(() => telemetryObservability?.shutdownObservability()) .catch(() => { /* swallow — telemetry must never prevent app.quit() */ }) diff --git a/src/main/ipc/cli.test.ts b/src/main/ipc/cli.test.ts index eeb98539751..3f4e9dea64e 100644 --- a/src/main/ipc/cli.test.ts +++ b/src/main/ipc/cli.test.ts @@ -23,7 +23,9 @@ vi.mock('../cli/wsl-cli-registration-registry', () => ({ recordWslCliRegistrationInstalled: recordInstalledMock, recordWslCliRegistrationRemoved: recordRemovedMock })) -vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../persistence-data-path', () => ({ + getCanonicalUserDataPath: () => '/canonical-user-data' +})) vi.mock('../startup/hydrate-shell-path', () => ({ hydrateShellPath: vi.fn(async () => ({ ok: false })), mergePathSegments: vi.fn() diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index 659c69d9c02..15156f51b81 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -7,7 +7,7 @@ import { } from '../cli/wsl-cli-registration-registry' import { WslCliInstaller } from '../cli/wsl-cli-installer' import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation' -import { getCanonicalUserDataPath } from '../persistence' +import { getCanonicalUserDataPath } from '../persistence-data-path' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getDefaultWslDistro } from '../wsl' diff --git a/src/main/ipc/runtime-environment-request-connections.test.ts b/src/main/ipc/runtime-environment-request-connections.test.ts new file mode 100644 index 00000000000..d7024b3cdab --- /dev/null +++ b/src/main/ipc/runtime-environment-request-connections.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PairingOffer } from '../../shared/pairing' +import { + MAIN_THREAD_DIAGNOSTICS_ENV, + drainRemoteRpcRequestStats +} from '../diagnostics/main-thread-churn-probe' + +const { oneShotRequest, dedicatedSubscription, cachedRequest, sharedRequest, sharedSubscription } = + vi.hoisted(() => ({ + oneShotRequest: vi.fn(), + dedicatedSubscription: vi.fn(), + cachedRequest: vi.fn(), + sharedRequest: vi.fn(), + sharedSubscription: vi.fn() + })) + +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: oneShotRequest, + subscribeRemoteRuntimeRequest: dedicatedSubscription +})) + +vi.mock('../../shared/remote-runtime-request-connection', () => ({ + RemoteRuntimeRequestConnection: class { + request = cachedRequest + close(): void {} + } +})) + +vi.mock('../../shared/remote-runtime-shared-control-connection', () => ({ + RemoteRuntimeSharedControlConnection: class { + request = sharedRequest + subscribe = sharedSubscription + close(): void {} + getDiagnostics(): null { + return null + } + reconnectNow(): void {} + retryNow(): boolean { + return false + } + } +})) + +import { + closeAllRemoteRuntimeRequestConnections, + sendRemoteRuntimeConnectionRequest, + sendRemoteRuntimeOneShotRequest, + sendRemoteRuntimeSharedControlRequest, + subscribeRemoteRuntimeDedicatedRequest, + subscribeRemoteRuntimeSharedControlRequest +} from './runtime-environment-request-connections' + +const pairing: PairingOffer = { + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'secret', + publicKeyB64: 'secret' +} + +afterEach(() => { + closeAllRemoteRuntimeRequestConnections() + drainRemoteRpcRequestStats() + vi.unstubAllEnvs() + vi.resetAllMocks() +}) + +describe('runtime environment RPC diagnostics', () => { + it('counts each selected outbound transport once, including rejected requests', async () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + oneShotRequest.mockResolvedValue({ ok: true }) + cachedRequest.mockRejectedValue(new Error('cached failure')) + sharedRequest.mockResolvedValue({ ok: true }) + dedicatedSubscription.mockResolvedValue({ requestId: 'dedicated' }) + sharedSubscription.mockResolvedValue({ requestId: 'shared' }) + + await sendRemoteRuntimeOneShotRequest(pairing, 'status.get', undefined, 1_000) + await expect( + sendRemoteRuntimeConnectionRequest('environment-1', pairing, 'terminal.send', {}, 1_000) + ).rejects.toThrow('cached failure') + await sendRemoteRuntimeSharedControlRequest('environment-1', pairing, 'git.status', {}, 1_000) + await subscribeRemoteRuntimeDedicatedRequest(pairing, 'terminal.multiplex', {}, 1_000, { + onResponse: vi.fn(), + onError: vi.fn() + }) + await subscribeRemoteRuntimeSharedControlRequest( + 'environment-1', + pairing, + 'clientEvents.subscribe', + {}, + 1_000, + { onResponse: vi.fn(), onError: vi.fn() } + ) + + expect(drainRemoteRpcRequestStats()).toEqual({ + 'status.get': { count: 1 }, + 'terminal.send': { count: 1 }, + 'git.status': { count: 1 }, + 'terminal.multiplex': { count: 1 }, + 'clientEvents.subscribe': { count: 1 } + }) + }) +}) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 361264d917e..c73b1eb2445 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,11 +1,21 @@ import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription, + type RemoteRuntimeSubscriptionCallbacks +} from '../../shared/remote-runtime-client' import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection' import type { RemoteRuntimeSharedConnectionDiagnostics, RemoteRuntimeSharedSubscription } from '../../shared/remote-runtime-shared-control-types' +import { recordRemoteRpcRequest } from '../diagnostics/main-thread-churn-probe' type CachedRuntimeConnection = { pairingKey: string @@ -20,6 +30,17 @@ type CachedSharedControlConnection = { const requestConnections = new Map() const sharedControlConnections = new Map() +export function sendRemoteRuntimeOneShotRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number, + envelope?: RuntimeOrchestrationEnvelope +): Promise> { + recordRemoteRpcRequest(method) + return sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope) +} + export function sendRemoteRuntimeConnectionRequest( environmentId: string, pairing: PairingOffer, @@ -37,6 +58,7 @@ export function sendRemoteRuntimeConnectionRequest( } requestConnections.set(environmentId, cached) } + recordRemoteRpcRequest(method) return cached.connection.request(method, params, timeoutMs) } @@ -63,9 +85,21 @@ export function sendRemoteRuntimeSharedControlRequest( params: unknown, timeoutMs: number ): Promise> { + recordRemoteRpcRequest(method) return getSharedControlConnection(environmentId, pairing).request(method, params, timeoutMs) } +export function subscribeRemoteRuntimeDedicatedRequest( + pairing: PairingOffer, + method: string, + params: unknown, + timeoutMs: number, + callbacks: RemoteRuntimeSubscriptionCallbacks +): Promise { + recordRemoteRpcRequest(method) + return subscribeRemoteRuntimeRequest(pairing, method, params, timeoutMs, callbacks) +} + export function subscribeRemoteRuntimeSharedControlRequest( environmentId: string, pairing: PairingOffer, @@ -79,6 +113,7 @@ export function subscribeRemoteRuntimeSharedControlRequest( onClose?: () => void } ): Promise { + recordRemoteRpcRequest(method) return getSharedControlConnection(environmentId, pairing).subscribe( method, params, diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts index 4603ff69d70..4b3761cef6c 100644 --- a/src/main/ipc/runtime-environment-shared-control-support.ts +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -1,11 +1,11 @@ import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' import { markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { getPreferredPairingOffer, KnownRuntimeEnvironment } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' +import { sendRemoteRuntimeOneShotRequest } from './runtime-environment-request-connections' const sharedControlSupport = new Map }>() @@ -30,7 +30,7 @@ export async function supportsSharedControl( } let resolvedCacheKey = cacheKey const check = (async () => { - const response = await sendRemoteRuntimeRequest( + const response = await sendRemoteRuntimeOneShotRequest( pairing, 'status.get', undefined, diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 852b97273c5..055f0c08c1d 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -5,17 +5,15 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' -import { - sendRemoteRuntimeRequest, - subscribeRemoteRuntimeRequest, - type RemoteRuntimeSubscription -} from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' import { reconnectRemoteRuntimeSharedControlConnection, sendRemoteRuntimeConnectionRequest, + sendRemoteRuntimeOneShotRequest, sendRemoteRuntimeSharedControlRequest, + subscribeRemoteRuntimeDedicatedRequest, subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections' import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' @@ -40,7 +38,7 @@ export async function getRuntimeEnvironmentStatus( const pairing = getPreferredPairingOffer(environment) let response: RuntimeRpcResponse try { - response = await sendRemoteRuntimeRequest( + response = await sendRemoteRuntimeOneShotRequest( pairing, 'status.get', undefined, @@ -106,7 +104,7 @@ export async function callRuntimeEnvironment( endpoint = pairing.endpoint const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS if (envelope) { - const response = await sendRemoteRuntimeRequest( + const response = await sendRemoteRuntimeOneShotRequest( pairing, method, params, @@ -144,7 +142,12 @@ export async function callRuntimeEnvironment( } // Why: startup/control-plane RPCs use the proven one-shot path so repo // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + const response = await sendRemoteRuntimeOneShotRequest( + pairing, + method, + params, + effectiveTimeoutMs + ) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response }) @@ -221,7 +224,7 @@ export async function subscribeRuntimeEnvironment( callbacksWithMarkUsed ) } - return await subscribeRemoteRuntimeRequest( + return await subscribeRemoteRuntimeDedicatedRequest( pairing, method, params, diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index a261625a6f0..aa035d6437a 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -56,8 +56,10 @@ vi.mock('../../shared/remote-runtime-client', () => ({ })) vi.mock('./runtime-environment-request-connections', () => ({ + sendRemoteRuntimeOneShotRequest: sendRemoteRuntimeRequestMock, sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeDedicatedRequest: subscribeRemoteRuntimeRequestMock, subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 764ca153943..127486fb31f 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -9,7 +9,9 @@ const watchOptions: { onPrompt: () => void }[] = [] vi.mock('./codex-accounts/fs-utils', () => ({ writeFileAtomically: (...args: unknown[]) => writeFileAtomically(...args) })) -vi.mock('./persistence', () => ({ getCanonicalUserDataPath: () => '/tmp/orca-tcc-notice-test' })) +vi.mock('./persistence-data-path', () => ({ + getCanonicalUserDataPath: () => '/tmp/orca-tcc-notice-test' +})) vi.mock('./macos-tcc-prompt-watch', () => ({ MacosTccPromptWatch: class { constructor(options: { onPrompt: () => void }) { diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index d76b0470453..098ddc059d6 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import type { BrowserWindow } from 'electron' import { writeFileAtomically } from './codex-accounts/fs-utils' -import { getCanonicalUserDataPath } from './persistence' +import { getCanonicalUserDataPath } from './persistence-data-path' import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' /** diff --git a/src/main/opencode-usage/opencode-usage-file-path.test.ts b/src/main/opencode-usage/opencode-usage-file-path.test.ts new file mode 100644 index 00000000000..c74680b93fa --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-file-path.test.ts @@ -0,0 +1,36 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +describe('OpenCode usage file path', () => { + const earlyUserDataPath = join('early-root', 'profile') + const lateUserDataPath = join('late-root', 'profile') + + beforeEach(() => { + userDataPath = earlyUserDataPath + vi.resetModules() + }) + + it('keeps the path captured before the Electron app name changes', async () => { + const { getOpenCodeUsageFilePath, initOpenCodeUsagePath } = + await import('./opencode-usage-file-path') + + initOpenCodeUsagePath() + userDataPath = lateUserDataPath + + expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json')) + }) + + it('caches the fallback path when startup initialization was skipped', async () => { + const { getOpenCodeUsageFilePath } = await import('./opencode-usage-file-path') + + expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json')) + userDataPath = lateUserDataPath + expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json')) + }) +}) diff --git a/src/main/opencode-usage/opencode-usage-file-path.ts b/src/main/opencode-usage/opencode-usage-file-path.ts new file mode 100644 index 00000000000..8b1fdb25fda --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-file-path.ts @@ -0,0 +1,16 @@ +import { app } from 'electron' +import { join } from 'node:path' + +let openCodeUsageFilePath: string | null = null + +export function initOpenCodeUsagePath(): void { + // Why: app.setName can change userData casing, so capture the configured path before app readiness. + openCodeUsageFilePath = join(app.getPath('userData'), 'orca-opencode-usage.json') +} + +export function getOpenCodeUsageFilePath(): string { + if (!openCodeUsageFilePath) { + openCodeUsageFilePath = join(app.getPath('userData'), 'orca-opencode-usage.json') + } + return openCodeUsageFilePath +} diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index b926f32651c..b270be73748 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ -import { app } from 'electron' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import type { OpenCodeUsageBreakdownKind, @@ -17,14 +16,15 @@ import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' +import { getOpenCodeUsageFilePath } from './opencode-usage-file-path' + +export { initOpenCodeUsagePath } from './opencode-usage-file-path' // Why: v2 adds per-database session ownership (stale sibling-copy dedupe). // Older caches were built without it and can carry doubled sessions (#8006). const SCHEMA_VERSION = 2 const STALE_MS = 5 * 60_000 -let _openCodeUsageFile: string | null = null - function getDefaultState(): OpenCodeUsagePersistedState { return { schemaVersion: SCHEMA_VERSION, @@ -59,17 +59,6 @@ export function normalizePersistedState( } } -export function initOpenCodeUsagePath(): void { - _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') -} - -function getOpenCodeUsageFile(): string { - if (!_openCodeUsageFile) { - _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') - } - return _openCodeUsageFile -} - function getRangeCutoff(range: OpenCodeUsageRange): string | null { if (range === 'all') { return null @@ -160,7 +149,7 @@ export class OpenCodeUsageStore { private load(): OpenCodeUsagePersistedState { try { - const usageFile = getOpenCodeUsageFile() + const usageFile = getOpenCodeUsageFilePath() if (!existsSync(usageFile)) { return getDefaultState() } @@ -180,7 +169,7 @@ export class OpenCodeUsageStore { } private writeToDisk(): void { - const usageFile = getOpenCodeUsageFile() + const usageFile = getOpenCodeUsageFilePath() const dir = dirname(usageFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) diff --git a/src/main/persistence-data-path.test.ts b/src/main/persistence-data-path.test.ts new file mode 100644 index 00000000000..3abc4d18be8 --- /dev/null +++ b/src/main/persistence-data-path.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SecureFile from '../shared/secure-file' +import { E2EE_KEYPAIR_FILENAME, DEVICE_REGISTRY_FILENAME } from './runtime/mobile-pairing-files' + +const pathMocks = vi.hoisted(() => ({ + appState: { userData: '' }, + hardenExistingSecureFile: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => pathMocks.appState.userData }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf8') + } +})) + +vi.mock('../shared/secure-file', async (importOriginal) => ({ + ...(await importOriginal()), + hardenExistingSecureFile: pathMocks.hardenExistingSecureFile +})) + +describe('persistence data path state', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-persistence-path-')) + pathMocks.hardenExistingSecureFile.mockReset() + vi.resetModules() + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.resetModules() + }) + + it('keeps the fallback data file and userData path in one captured state', async () => { + const fallbackPath = join(root, 'fallback-user-data') + const latePath = join(root, 'late-user-data') + pathMocks.appState.userData = fallbackPath + const paths = await import('./persistence-data-path') + + expect(paths.getPersistenceDataFilePath()).toBe(join(fallbackPath, 'orca-data.json')) + pathMocks.appState.userData = latePath + expect(paths.getCanonicalUserDataPath()).toBe(fallbackPath) + expect(paths.getPersistenceDataFilePath()).toBe(join(fallbackPath, 'orca-data.json')) + + paths.initDataPath() + expect(paths.getCanonicalUserDataPath()).toBe(latePath) + expect(paths.getPersistenceDataFilePath()).toBe(join(latePath, 'orca-data.json')) + }) + + it('shares the initialized state through persistence compatibility re-exports', async () => { + const canonicalPath = join(root, 'canonical-user-data') + pathMocks.appState.userData = canonicalPath + const paths = await import('./persistence-data-path') + const persistence = await import('./persistence') + + expect(persistence.initDataPath).toBe(paths.initDataPath) + expect(persistence.getCanonicalUserDataPath).toBe(paths.getCanonicalUserDataPath) + persistence.initDataPath() + pathMocks.appState.userData = join(root, 'late-user-data') + + expect(paths.getCanonicalUserDataPath()).toBe(canonicalPath) + expect(paths.getPersistenceDataFilePath()).toBe(join(canonicalPath, 'orca-data.json')) + }) + + it('migrates and hardens the complete mobile credential pair', async () => { + const sourcePath = join(root, 'late-user-data') + const canonicalPath = join(root, 'canonical-user-data') + mkdirSync(sourcePath, { recursive: true }) + mkdirSync(canonicalPath, { recursive: true }) + const devices = '{"devices":"source"}' + const keypair = '{"keypair":"source"}' + writeFileSync(join(sourcePath, DEVICE_REGISTRY_FILENAME), devices) + writeFileSync(join(sourcePath, E2EE_KEYPAIR_FILENAME), keypair) + pathMocks.appState.userData = canonicalPath + const paths = await import('./persistence-data-path') + paths.initDataPath() + + paths.migrateMobilePairingDataToCanonicalUserDataPath(sourcePath) + + const deviceTarget = join(canonicalPath, DEVICE_REGISTRY_FILENAME) + const keypairTarget = join(canonicalPath, E2EE_KEYPAIR_FILENAME) + expect(readFileSync(deviceTarget, 'utf8')).toBe(devices) + expect(readFileSync(keypairTarget, 'utf8')).toBe(keypair) + expect(pathMocks.hardenExistingSecureFile.mock.calls).toEqual([[deviceTarget], [keypairTarget]]) + }) +}) diff --git a/src/main/persistence-data-path.ts b/src/main/persistence-data-path.ts new file mode 100644 index 00000000000..e1155bc8b2e --- /dev/null +++ b/src/main/persistence-data-path.ts @@ -0,0 +1,52 @@ +import { app } from 'electron' +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { hardenExistingSecureFile } from '../shared/secure-file' +import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' + +let dataFile: string | null = null +let userDataDir: string | null = null + +// Why: capture after dev redirection but before app.setName changes path casing on case-sensitive filesystems. +export function initDataPath(): void { + userDataDir = app.getPath('userData') + dataFile = join(userDataDir, 'orca-data.json') +} + +export function getPersistenceDataFilePath(): string { + if (!dataFile) { + userDataDir = app.getPath('userData') + dataFile = join(userDataDir, 'orca-data.json') + } + return dataFile +} + +export function getCanonicalUserDataPath(): string { + userDataDir ??= app.getPath('userData') + return userDataDir +} + +export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { + const targetUserDataDir = getCanonicalUserDataPath() + if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { + return + } + + const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ + sourcePath: join(sourceUserDataDir, fileName), + targetPath: join(targetUserDataDir, fileName) + })) + if ( + migrations.some(({ sourcePath }) => !existsSync(sourcePath)) || + migrations.some(({ targetPath }) => existsSync(targetPath)) + ) { + return + } + + mkdirSync(targetUserDataDir, { recursive: true }) + for (const { sourcePath, targetPath } of migrations) { + copyFileSync(sourcePath, targetPath) + // Why: copied credentials must be current-user-only on every platform, including Windows ACLs. + hardenExistingSecureFile(targetPath) + } +} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d0f27346e83..d63b59e4559 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { app, safeStorage } from 'electron' +import { safeStorage } from 'electron' import { readFileSync, writeFileSync, @@ -79,14 +79,18 @@ import { buildWorkspaceRunContext } from '../shared/task-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' -import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession } from './orca-profiles/profile-project-session-state' -import { hardenExistingSecureFile } from '../shared/secure-file' +import { getPersistenceDataFilePath as getDataFile } from './persistence-data-path' +export { + getCanonicalUserDataPath, + initDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from './persistence-data-path' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type RemovedSshTargetTombstone, @@ -329,27 +333,6 @@ function retireLegacyInstructionsForClearedTextActionRecipes( return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi } -// Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide); -// per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment. -let _dataFile: string | null = null -let _userDataDir: string | null = null - -export function initDataPath(): void { - const userDataDir = app.getPath('userData') - _userDataDir = userDataDir - _dataFile = join(userDataDir, 'orca-data.json') -} - -function getDataFile(): string { - if (!_dataFile) { - // Safety fallback — should not be hit in normal startup. - const userDataDir = app.getPath('userData') - _userDataDir = userDataDir - _dataFile = join(userDataDir, 'orca-data.json') - } - return _dataFile -} - // Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle. // Snapshotted best-effort at quit for instant badges next launch; safe to lose. function getGithubCacheFile(dataFile = getDataFile()): string { @@ -434,49 +417,6 @@ function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache' return null } -/** - * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. - * - * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. - */ -export function getCanonicalUserDataPath(): string { - if (!_userDataDir) { - // Safety fallback — should not be hit in normal startup. - _userDataDir = app.getPath('userData') - } - return _userDataDir -} - -/** - * Copy legacy mobile pairing credentials into the canonical userData directory. - * - * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. - */ -export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { - const targetUserDataDir = getCanonicalUserDataPath() - if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { - return - } - - const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ - sourcePath: join(sourceUserDataDir, fileName), - targetPath: join(targetUserDataDir, fileName) - })) - if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { - return - } - if (migrations.some(({ targetPath }) => existsSync(targetPath))) { - return - } - - mkdirSync(targetUserDataDir, { recursive: true }) - for (const { sourcePath, targetPath } of migrations) { - copyFileSync(sourcePath, targetPath) - // Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key). - hardenExistingSecureFile(targetPath) - } -} - // Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. const BACKUP_COUNT = 5 const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 diff --git a/src/main/providers/ssh-filesystem-file-upload.ts b/src/main/providers/ssh-filesystem-file-upload.ts index 6fa83145d69..48555bdfa02 100644 --- a/src/main/providers/ssh-filesystem-file-upload.ts +++ b/src/main/providers/ssh-filesystem-file-upload.ts @@ -1,5 +1,5 @@ import type { SFTPWrapper } from 'ssh2' -import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload' +import { loadSftpUploadCapability } from '../ssh/sftp-upload-capability' import type { FileUploadSession } from './types' export type SftpFactory = () => Promise @@ -24,12 +24,13 @@ export async function openSshFileUploadSession( if (!createSftp) { throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') } + const { uploadFile } = await loadSftpUploadCapability() const sftp = await createSftp() return { // Why: one session covers the whole import so normal SSH keeps its prior // channel count even when a directory contains many files. uploadFile: (sourcePath, destinationPath, options) => - uploadFileViaSftp(sftp, sourcePath, destinationPath, options), + uploadFile(sftp, sourcePath, destinationPath, options), close: () => sftp.end() } } diff --git a/src/main/providers/ssh-filesystem-provider-download-boundary.test.ts b/src/main/providers/ssh-filesystem-provider-download-boundary.test.ts new file mode 100644 index 00000000000..a66f4454e5b --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-download-boundary.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +function createMockMux() { + return { + request: vi.fn(), + onNotification: vi.fn(() => () => {}) + } +} + +afterEach(() => { + vi.doUnmock('./ssh-filesystem-download') + vi.resetModules() +}) + +describe('SshFilesystemProvider download boundary', () => { + it('does not load the SFTP download capability for raw transfers', async () => { + let capabilityLoaded = false + vi.doMock('./ssh-filesystem-download', () => { + capabilityLoaded = true + return { + downloadFileViaSftp: vi.fn(), + downloadFolderViaSftp: vi.fn() + } + }) + const { SshFilesystemProvider } = await import('./ssh-filesystem-provider') + const downloadFile = vi.fn().mockResolvedValue(undefined) + const provider = new SshFilesystemProvider('conn-1', createMockMux() as never, undefined, { + downloadFile + }) + + expect(capabilityLoaded).toBe(false) + await provider.downloadFile('/remote/archive.zip', '/local/archive.zip') + + expect(capabilityLoaded).toBe(false) + expect(downloadFile).toHaveBeenCalledWith('/remote/archive.zip', '/local/archive.zip') + }) + + it('loads folder downloads on demand and preserves Windows path mapping', async () => { + const downloadFolderViaSftp = vi.fn().mockResolvedValue(undefined) + let capabilityLoads = 0 + vi.doMock('./ssh-filesystem-download', () => { + capabilityLoads += 1 + return { + downloadFileViaSftp: vi.fn(), + downloadFolderViaSftp + } + }) + const { SshFilesystemProvider } = await import('./ssh-filesystem-provider') + const createSftp = vi.fn() + const provider = new SshFilesystemProvider( + 'conn-1', + createMockMux() as never, + createSftp, + undefined, + getRemoteHostPlatform('win32-x64') + ) + const controller = new AbortController() + + expect(capabilityLoads).toBe(0) + await provider.downloadFolder!('C:\\remote\\src', 'C:\\local\\src', { + signal: controller.signal + }) + + expect(capabilityLoads).toBe(1) + expect(downloadFolderViaSftp).toHaveBeenCalledWith( + createSftp, + 'C:\\remote\\src', + 'C:\\local\\src', + { signal: controller.signal, windowsRemotePaths: true } + ) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts index a3b1b840be3..81994e1cfbf 100644 --- a/src/main/providers/ssh-filesystem-provider-sftp.ts +++ b/src/main/providers/ssh-filesystem-provider-sftp.ts @@ -1,5 +1,4 @@ import type { FileEntryWithStats, SFTPWrapper, Stats } from 'ssh2' -import type { FileStat } from './types' const ABORTED_SFTP_OPERATION_GRACE_MS = 5_000 @@ -55,34 +54,6 @@ function waitForSftpCallback( }) } -export function fileStatFromSftpStats(stats: Stats): FileStat { - let type: FileStat['type'] = 'file' - if (stats.isDirectory()) { - type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' - } - const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink - return { - size: stats.size, - type, - mtime: stats.mtime * 1000, - ...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {}) - } -} - -export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.lstat(filePath, (err, stats) => { - if (err) { - reject(err) - return - } - resolve(fileStatFromSftpStats(stats)) - }) - }) -} - export function fastGetViaSftp( sftp: SFTPWrapper, sourcePath: string, diff --git a/src/main/providers/ssh-filesystem-provider-upload-boundary.test.ts b/src/main/providers/ssh-filesystem-provider-upload-boundary.test.ts new file mode 100644 index 00000000000..4eea97e61fd --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-upload-boundary.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +function createMockMux() { + return { + request: vi.fn(), + onNotification: vi.fn(() => () => {}) + } +} + +afterEach(() => { + vi.doUnmock('../ssh/sftp-upload') + vi.resetModules() +}) + +describe('SshFilesystemProvider upload boundary', () => { + it('does not load the SFTP upload capability for raw transfers', async () => { + let capabilityLoaded = false + vi.doMock('../ssh/sftp-upload', () => { + capabilityLoaded = true + return { + uploadBuffer: vi.fn(), + uploadFile: vi.fn() + } + }) + const { SshFilesystemProvider } = await import('./ssh-filesystem-provider') + const writeBuffer = vi.fn().mockResolvedValue(undefined) + const rawSession = { uploadFile: vi.fn(), close: vi.fn() } + const provider = new SshFilesystemProvider('conn-1', createMockMux() as never, undefined, { + writeBuffer, + openFileUploadSession: vi.fn().mockResolvedValue(rawSession) + }) + + await provider.writeFileBase64Chunk('/remote/image.png', 'cG5n', true) + await expect(provider.openFileUploadSession()).resolves.toBe(rawSession) + + expect(capabilityLoaded).toBe(false) + expect(writeBuffer).toHaveBeenCalledWith('/remote/image.png', Buffer.from('png'), { + append: true, + exclusive: false + }) + }) + + it('loads binary SFTP uploads on demand and preserves write flags', async () => { + const uploadBuffer = vi.fn().mockResolvedValue(undefined) + let capabilityLoads = 0 + vi.doMock('../ssh/sftp-upload', () => { + capabilityLoads += 1 + return { + uploadBuffer, + uploadFile: vi.fn() + } + }) + const { SshFilesystemProvider } = await import('./ssh-filesystem-provider') + const sftp = { end: vi.fn() } + const provider = new SshFilesystemProvider( + 'conn-1', + createMockMux() as never, + vi.fn().mockResolvedValue(sftp) as never + ) + + expect(capabilityLoads).toBe(0) + await provider.writeFileBase64Chunk('/remote/image.png', 'cG5n', true) + + expect(capabilityLoads).toBe(1) + expect(uploadBuffer).toHaveBeenCalledWith(sftp, Buffer.from('png'), '/remote/image.png', { + append: true, + exclusive: false + }) + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('loads file SFTP uploads when a session opens and keeps its session semantics', async () => { + const uploadFile = vi.fn().mockResolvedValue(undefined) + let capabilityLoads = 0 + vi.doMock('../ssh/sftp-upload', () => { + capabilityLoads += 1 + return { + uploadBuffer: vi.fn(), + uploadFile + } + }) + const { SshFilesystemProvider } = await import('./ssh-filesystem-provider') + const sftp = { end: vi.fn() } + const provider = new SshFilesystemProvider( + 'conn-1', + createMockMux() as never, + vi.fn().mockResolvedValue(sftp) as never + ) + + expect(capabilityLoads).toBe(0) + const session = await provider.openFileUploadSession() + await session.uploadFile('/local/image.png', '/remote/image.png', { exclusive: true }) + session.close() + + expect(capabilityLoads).toBe(1) + expect(uploadFile).toHaveBeenCalledWith(sftp, '/local/image.png', '/remote/image.png', { + exclusive: true + }) + expect(sftp.end).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 74f31b8e6bf..4e31d97d5b6 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,13 +1,9 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' -import { uploadBuffer } from '../ssh/sftp-upload' -import { lstatViaSftp } from './ssh-filesystem-provider-sftp' -import { - downloadFileViaSftp, - downloadFolderViaSftp, - type SftpFactory -} from './ssh-filesystem-download' +import { loadSftpUploadCapability } from '../ssh/sftp-upload-capability' +import type { SftpFactory } from './ssh-filesystem-download' import { openSshFileUploadSession, type SshRawTransferOptions } from './ssh-filesystem-file-upload' +import { lstatViaSftp } from './ssh-filesystem-sftp-stat' import { closeSshFilesystemWatch, registerSshFilesystemWatch, @@ -52,11 +48,13 @@ export class SshFilesystemProvider implements IFilesystemProvider { // omitting this method makes folder capability truthful at the provider boundary. // windowsRemotePaths is provider-owned (from host platform), not a caller option. const windowsRemotePaths = hostPlatform ? isWindowsRemoteHost(hostPlatform) : undefined - this.downloadFolder = (sourcePath, destinationPath, options) => - downloadFolderViaSftp(createSftp, sourcePath, destinationPath, { + this.downloadFolder = async (sourcePath, destinationPath, options) => { + const { downloadFolderViaSftp } = await import('./ssh-filesystem-download') + await downloadFolderViaSftp(createSftp, sourcePath, destinationPath, { ...options, windowsRemotePaths }) + } } this.unsubscribeNotifications = mux.onNotification((method, params) => @@ -136,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.rawTransfer.downloadFile(sourcePath, destinationPath) return } + if (!this.createSftp) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + const { downloadFileViaSftp } = await import('./ssh-filesystem-download') await downloadFileViaSftp(this.createSftp, sourcePath, destinationPath) } @@ -210,6 +212,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { try { // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes // directly so runtime uploads do not corrupt images, PDFs, or archives. + const { uploadBuffer } = await loadSftpUploadCapability() await uploadBuffer(sftp, contents, filePath, { append, exclusive: !append diff --git a/src/main/providers/ssh-filesystem-sftp-stat.ts b/src/main/providers/ssh-filesystem-sftp-stat.ts new file mode 100644 index 00000000000..ec1ccaa8d49 --- /dev/null +++ b/src/main/providers/ssh-filesystem-sftp-stat.ts @@ -0,0 +1,30 @@ +import type { SFTPWrapper, Stats } from 'ssh2' +import type { FileStat } from './types' + +export function fileStatFromSftpStats(stats: Stats): FileStat { + let type: FileStat['type'] = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink + return { + size: stats.size, + type, + mtime: stats.mtime * 1000, + ...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {}) + } +} + +export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(filePath, (err, stats) => { + if (err) { + reject(err) + return + } + resolve(fileStatFromSftpStats(stats)) + }) + }) +} diff --git a/src/main/serve-update-handoff.test.ts b/src/main/serve-update-handoff.test.ts index d649243a8f5..3e344ffd693 100644 --- a/src/main/serve-update-handoff.test.ts +++ b/src/main/serve-update-handoff.test.ts @@ -15,7 +15,9 @@ const { appMock, getCanonicalUserDataPathMock } = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ app: appMock })) -vi.mock('./persistence', () => ({ getCanonicalUserDataPath: getCanonicalUserDataPathMock })) +vi.mock('./persistence-data-path', () => ({ + getCanonicalUserDataPath: getCanonicalUserDataPathMock +})) describe('serve update handoff', () => { let root: string diff --git a/src/main/serve-update-handoff.ts b/src/main/serve-update-handoff.ts index ab831a90d49..d30277ff76b 100644 --- a/src/main/serve-update-handoff.ts +++ b/src/main/serve-update-handoff.ts @@ -8,7 +8,7 @@ import { type ServeSupervisorMessage, type ServeUpdateHandoffState } from '../shared/serve-update-handoff' -import { getCanonicalUserDataPath } from './persistence' +import { getCanonicalUserDataPath } from './persistence-data-path' function getConfiguredHandoffPath(): string | null { const configuredPath = process.env[SERVE_UPDATE_HANDOFF_PATH_ENV] diff --git a/src/main/ssh/sftp-upload-capability.ts b/src/main/ssh/sftp-upload-capability.ts new file mode 100644 index 00000000000..defd4216fa2 --- /dev/null +++ b/src/main/ssh/sftp-upload-capability.ts @@ -0,0 +1,19 @@ +import type { + mkdirSftp, + uploadBuffer, + uploadDirectory, + uploadFile, + writeStringViaSftp +} from './sftp-upload' + +export type SftpUploadCapability = { + mkdirSftp: typeof mkdirSftp + uploadBuffer: typeof uploadBuffer + uploadDirectory: typeof uploadDirectory + uploadFile: typeof uploadFile + writeStringViaSftp: typeof writeStringViaSftp +} + +export function loadSftpUploadCapability(): Promise { + return import('./sftp-upload') +} diff --git a/src/main/ssh/ssh-channel-multiplexer-synthetic-latency.benchmark.test.ts b/src/main/ssh/ssh-channel-multiplexer-synthetic-latency.benchmark.test.ts new file mode 100644 index 00000000000..8b575d7ef71 --- /dev/null +++ b/src/main/ssh/ssh-channel-multiplexer-synthetic-latency.benchmark.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAIN_THREAD_DIAGNOSTICS_ENV, + drainRemoteRpcRequestStats +} from '../diagnostics/main-thread-churn-probe' +import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' +import { encodeFrame, MessageType } from './relay-protocol' + +function responseFrame( + id: number, + seq: number, + result?: unknown, + error?: { code: number; message: string } +): Buffer { + return encodeFrame( + MessageType.Regular, + seq, + 0, + Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, ...(error ? { error } : { result }) })) + ) +} + +function createTransport(): MultiplexerTransport & { receive: (data: Buffer) => void } { + let receive = (_data: Buffer): void => {} + return { + write: () => {}, + onData: (callback) => { + receive = callback + }, + onClose: () => {}, + receive: (data) => receive(data) + } +} + +describe('synthetic slow-SSH RPC benchmark', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + drainRemoteRpcRequestStats() + }) + + it('reports controlled latency and counts each outcome once', async () => { + const syntheticLatencyMs = 125 + const transport = createTransport() + const mux = new SshChannelMultiplexer(transport) + const abortController = new AbortController() + const startedAt = Date.now() + + const success = mux.request('git.status') + const remoteError = mux.request('git.history') + const cancellation = mux.request('git.diff', {}, { signal: abortController.signal }) + const preAbortedController = new AbortController() + preAbortedController.abort() + const preAborted = mux.request('git.status', {}, { signal: preAbortedController.signal }) + const outcomesPromise = Promise.allSettled([success, remoteError, cancellation, preAborted]) + + setTimeout(() => { + transport.receive(responseFrame(1, 1, { entries: [] })) + transport.receive(responseFrame(2, 2, undefined, { code: -32_000, message: 'synthetic' })) + abortController.abort() + }, syntheticLatencyMs) + + await vi.advanceTimersByTimeAsync(syntheticLatencyMs) + const outcomes = await outcomesPromise + const rpcs = drainRemoteRpcRequestStats() + const report = { + mode: 'synthetic-ssh-rpc-latency', + latencyKind: 'controlled-in-memory-transport', + syntheticLatencyMs, + elapsedMs: Date.now() - startedAt, + rpcCount: Object.values(rpcs).reduce((sum, stats) => sum + stats.count, 0), + rpcs + } + + console.info(`[synthetic-ssh-rpc] ${JSON.stringify(report)}`) + expect(outcomes.map((outcome) => outcome.status)).toEqual([ + 'fulfilled', + 'rejected', + 'rejected', + 'rejected' + ]) + expect(report).toEqual({ + mode: 'synthetic-ssh-rpc-latency', + latencyKind: 'controlled-in-memory-transport', + syntheticLatencyMs: 125, + elapsedMs: 125, + rpcCount: 3, + rpcs: { + 'git.status': { count: 1 }, + 'git.history': { count: 1 }, + 'git.diff': { count: 1 } + } + }) + + mux.dispose() + }) + + it('counts a failed send attempt but not a disposed request', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const transport = createTransport() + transport.write = () => { + throw new Error('synthetic write failure') + } + const mux = new SshChannelMultiplexer(transport) + + await expect(mux.request('git.status')).rejects.toThrow('SSH connection lost') + expect(drainRemoteRpcRequestStats()).toEqual({ 'git.status': { count: 1 } }) + + mux.dispose() + await expect(mux.request('git.status')).rejects.toThrow('Multiplexer disposed') + expect(drainRemoteRpcRequestStats()).toEqual({}) + }) +}) diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 9886b724755..b3d5c3a5f24 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -14,6 +14,7 @@ import { type JsonRpcResponse, type JsonRpcNotification } from './relay-protocol' +import { recordRemoteRpcRequest } from '../diagnostics/main-thread-churn-probe' export type MultiplexerTransport = { write: (data: Buffer) => void @@ -216,6 +217,7 @@ export class SshChannelMultiplexer { options.signal.addEventListener('abort', onAbort, { once: true }) } this.pendingRequests.set(id, { resolve, reject, timer, cleanup }) + recordRemoteRpcRequest(method) this.sendMessage(msg) }) } diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 224f888eadb..2fa5bca26b0 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -50,6 +50,7 @@ import { createLinkedSshFileTransferSignal, raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' +import { loadSftpUploadCapability } from './sftp-upload-capability' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { @@ -433,7 +434,7 @@ export class SshConnection { const transfer = (async (): Promise => { const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) linkedSignal.signal.throwIfAborted() - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + const { uploadDirectory } = await loadSftpUploadCapability() await uploadDirectory(sftp, localDir, targetDir) })() await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { @@ -480,7 +481,7 @@ export class SshConnection { async openFileUploadSession(options?: SshRemoteFileOptions): Promise { if (!this.useSystemSshTransport) { const sftp = await this.sftp() - const { uploadFile } = await import('./sftp-upload') + const { uploadFile } = await loadSftpUploadCapability() return { uploadFile: (localPath, remotePath, uploadOptions) => uploadFile(sftp, localPath, remotePath, uploadOptions), @@ -531,7 +532,7 @@ export class SshConnection { const write = (async (): Promise => { const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) linkedSignal.signal.throwIfAborted() - const { writeStringViaSftp } = await import('./sftp-upload') + const { writeStringViaSftp } = await loadSftpUploadCapability() await writeStringViaSftp(sftp, targetPath, contents) })() await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { @@ -561,7 +562,7 @@ export class SshConnection { if (!this.useSystemSshTransport) { const sftp = await this.sftp() try { - const { uploadBuffer } = await import('./sftp-upload') + const { uploadBuffer } = await loadSftpUploadCapability() await uploadBuffer(sftp, contents, remotePath, options) } finally { sftp.end() diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 80d2b78ced2..08d72395742 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -3,10 +3,31 @@ import { createSshOperationAbortError } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' +import { loadSftpUploadCapability, type SftpUploadCapability } from './sftp-upload-capability' -export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +export async function uploadFile( + ...args: Parameters +): Promise { + const { uploadFile: upload } = await loadSftpUploadCapability() + await upload(...args) +} + +export async function uploadDirectory( + ...args: Parameters +): Promise { + const { uploadDirectory: upload } = await loadSftpUploadCapability() + await upload(...args) +} + +export async function mkdirSftp( + ...args: Parameters +): Promise { + const { mkdirSftp: mkdir } = await loadSftpUploadCapability() + await mkdir(...args) +} + // ── Sentinel detection ──────────────────────────────────────────────── const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 diff --git a/src/main/ssh/ssh-relay-install-transfers.test.ts b/src/main/ssh/ssh-relay-install-transfers.test.ts new file mode 100644 index 00000000000..9173bf78649 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-transfers.test.ts @@ -0,0 +1,122 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +afterEach(() => { + vi.doUnmock('./sftp-upload') + vi.doUnmock('./sftp-namespace-resolution') + vi.resetModules() +}) + +function createSftp() { + const sftp = new EventEmitter() as EventEmitter & { end: ReturnType } + sftp.end = vi.fn(() => sftp.emit('close')) + return sftp +} + +describe('relay install SFTP transfers', () => { + it('keeps system SSH transfers outside the SFTP upload capability', async () => { + let capabilityLoaded = false + vi.doMock('./sftp-upload', () => { + capabilityLoaded = true + return { + uploadDirectory: vi.fn(), + writeStringViaSftp: vi.fn() + } + }) + const { uploadRelayDirectory, writeRelayFile } = await import('./ssh-relay-install-transfers') + const uploadDirectory = vi.fn().mockResolvedValue(undefined) + const writeFile = vi.fn().mockResolvedValue(undefined) + const conn = { uploadDirectory, writeFile } + const hostPlatform = getRemoteHostPlatform('linux-x64') + const signal = new AbortController().signal + + await uploadRelayDirectory(conn as never, '/local/relay', '/shell/relay', hostPlatform, { + signal + }) + await writeRelayFile(conn as never, hostPlatform, '/shell/relay/.version', '1.2.3', { signal }) + + expect(capabilityLoaded).toBe(false) + expect(uploadDirectory).toHaveBeenCalledWith('/local/relay', '/shell/relay', { + hostPlatform, + signal, + sftpNamespace: undefined + }) + expect(writeFile).toHaveBeenCalledWith('/shell/relay/.version', '1.2.3', { + hostPlatform, + signal, + sftpNamespace: undefined + }) + }) + + it('loads fallback transfers on demand after resolving the SFTP namespace', async () => { + const uploadDirectory = vi.fn().mockResolvedValue(undefined) + const writeStringViaSftp = vi.fn().mockResolvedValue(undefined) + let capabilityLoads = 0 + vi.doMock('./sftp-upload', () => { + capabilityLoads += 1 + return { uploadDirectory, writeStringViaSftp } + }) + const resolveSftpTransferPathIfMapped = vi + .fn() + .mockResolvedValueOnce('/mapped/relay') + .mockResolvedValueOnce('/mapped/relay/.version') + vi.doMock('./sftp-namespace-resolution', () => ({ + resolveSftpTransferPathIfMapped + })) + const { uploadRelayDirectory, writeRelayFile } = await import('./ssh-relay-install-transfers') + const firstSftp = createSftp() + const secondSftp = createSftp() + const conn = { + sftp: vi.fn().mockResolvedValueOnce(firstSftp).mockResolvedValueOnce(secondSftp) + } + const hostPlatform = getRemoteHostPlatform('linux-x64') + + expect(capabilityLoads).toBe(0) + await uploadRelayDirectory(conn as never, '/local/relay', '/shell/relay', hostPlatform) + await writeRelayFile(conn as never, hostPlatform, '/shell/relay/.version', '1.2.3') + + expect(capabilityLoads).toBe(1) + expect(uploadDirectory).toHaveBeenCalledWith(firstSftp, '/local/relay', '/mapped/relay') + expect(writeStringViaSftp).toHaveBeenCalledWith(secondSftp, '/mapped/relay/.version', '1.2.3') + expect(firstSftp.end).toHaveBeenCalledOnce() + expect(secondSftp.end).toHaveBeenCalledOnce() + }) + + it('closes the fallback SFTP session when an upload is aborted', async () => { + let startUpload: (() => void) | undefined + const uploadStarted = new Promise((resolve) => { + startUpload = resolve + }) + vi.doMock('./sftp-upload', () => ({ + uploadDirectory: vi.fn(() => { + startUpload?.() + return new Promise(() => {}) + }), + writeStringViaSftp: vi.fn() + })) + vi.doMock('./sftp-namespace-resolution', () => ({ + resolveSftpTransferPathIfMapped: vi.fn().mockResolvedValue('/mapped/relay') + })) + const { uploadRelayDirectory } = await import('./ssh-relay-install-transfers') + const sftp = createSftp() + const conn = { sftp: vi.fn().mockResolvedValue(sftp) } + const controller = new AbortController() + const transfer = uploadRelayDirectory( + conn as never, + '/local/relay', + '/shell/relay', + getRemoteHostPlatform('linux-x64'), + { signal: controller.signal } + ) + + await uploadStarted + controller.abort() + + await expect(transfer).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + expect(sftp.end).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-relay-install-transfers.ts b/src/main/ssh/ssh-relay-install-transfers.ts index decbcd103ee..97c47ed057f 100644 --- a/src/main/ssh/ssh-relay-install-transfers.ts +++ b/src/main/ssh/ssh-relay-install-transfers.ts @@ -5,7 +5,7 @@ import type { SFTPWrapper } from 'ssh2' import type { SshConnection } from './ssh-connection' -import { writeStringViaSftp } from './sftp-upload' +import { loadSftpUploadCapability } from './sftp-upload-capability' import { uploadDirectory } from './ssh-relay-deploy-helpers' import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' import { @@ -65,6 +65,7 @@ export async function writeRelayFile( sftpNamespace: options?.sftpNamespace }) options?.signal?.throwIfAborted() + const { writeStringViaSftp } = await loadSftpUploadCapability() await writeStringViaSftp(sftp, targetPath, contents) }) } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index a65ba6f91b5..4292e41d997 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -8,7 +8,7 @@ vi.mock('electron', () => ({ getAppPath: () => '/host/app' } })) -vi.mock('../persistence', () => ({ +vi.mock('../persistence-data-path', () => ({ getCanonicalUserDataPath: () => '/host/user-data' })) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 09db31d4770..d59175ca9c6 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -8,7 +8,7 @@ import { app } from 'electron' import { spawn as nodeSpawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' -import { getCanonicalUserDataPath } from '../persistence' +import { getCanonicalUserDataPath } from '../persistence-data-path' import { parseRemoteCliArgs } from './ssh-remote-cli-args' import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout' import { diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 276eaac6500..7f669d9eae3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -7,7 +7,7 @@ vi.mock('electron', () => ({ getAppPath: () => '/host/app' } })) -vi.mock('../persistence', () => ({ +vi.mock('../persistence-data-path', () => ({ getCanonicalUserDataPath: () => '/host/user-data' })) diff --git a/src/main/startup/account-runtime-coordination-startup-boundary.test.ts b/src/main/startup/account-runtime-coordination-startup-boundary.test.ts new file mode 100644 index 00000000000..dc07433f0f6 --- /dev/null +++ b/src/main/startup/account-runtime-coordination-startup-boundary.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/account-runtime-coordination-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('account-runtime coordination startup boundary', () => { + it('removes every candidate eager index value behind one owner', () => { + for (const moduleSpecifier of [ + './minimax/minimax-cookie-store', + './rate-limits/claude-rate-limit-target', + './rate-limits/codex-rate-limit-target', + './rate-limits/account-runtime-target-sync', + './claude-accounts/runtime-selection', + './claude-accounts/live-pty-gate' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./startup/account-runtime-coordination-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after crash/hang and before its first consumer or Store construction', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const crashInstallIndex = source.indexOf( + 'installCrashHangRuntimeStartupCapability(crashHangRuntime)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, crashInstallIndex) + const factoryIndex = source.indexOf( + 'const accountRuntimeCoordination = createAccountRuntimeCoordinationStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installAccountRuntimeCoordinationStartupCapability(accountRuntimeCoordination)', + factoryIndex + ) + const crashStoreIndex = source.indexOf( + 'crashReports = crashHangRuntime.CrashReportStore.fromUserData()', + installIndex + ) + const storeIndex = source.indexOf('store = createStoreStartupCapability(', crashStoreIndex) + + expect(importIndex).toBeGreaterThan(crashInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(crashStoreIndex).toBeGreaterThan(installIndex) + expect(storeIndex).toBeGreaterThan(crashStoreIndex) + }) + + it('preserves live-PTY hydration and account target ordering', () => { + const storeIndex = source.indexOf('store = createStoreStartupCapability(') + const attachIndex = source.indexOf( + 'accountRuntimeCoordination.attachClaudeLivePtyPersistence(store)', + storeIndex + ) + const drainIndex = source.indexOf( + 'accountRuntimeCoordination.onLiveClaudePtysDrained(() => {', + attachIndex + ) + const persistedIndex = source.indexOf( + 'const persistedClaudePtyIds = store.getClaudeLivePtySessionIds()', + drainIndex + ) + const seedIndex = source.indexOf( + 'accountRuntimeCoordination.seedLiveClaudePtysFromPersistence(persistedClaudePtyIds)', + persistedIndex + ) + const accountServicesIndex = source.indexOf( + 'const accountServices = createAccountServicesStartupCapability(', + seedIndex + ) + const codexTargetIndex = source.indexOf( + 'accountRuntimeCoordination.getInitialCodexRateLimitTarget(store.getSettings())', + accountServicesIndex + ) + const claudeTargetIndex = source.indexOf( + 'accountRuntimeCoordination.getInitialClaudeRateLimitTarget(store.getSettings())', + codexTargetIndex + ) + const syncIndex = source.indexOf( + 'accountRuntimeCoordination.createAccountRuntimeTargetSettingsSync(', + claudeTargetIndex + ) + + expect(attachIndex).toBeGreaterThan(storeIndex) + expect(drainIndex).toBeGreaterThan(attachIndex) + expect(persistedIndex).toBeGreaterThan(drainIndex) + expect(seedIndex).toBeGreaterThan(persistedIndex) + expect(accountServicesIndex).toBeGreaterThan(seedIndex) + expect(codexTargetIndex).toBeGreaterThan(accountServicesIndex) + expect(claudeTargetIndex).toBeGreaterThan(codexTargetIndex) + expect(syncIndex).toBeGreaterThan(claudeTargetIndex) + }) + + it('preserves deferred MiniMax and inactive Claude resolver identities', () => { + const miniMaxResolverIndex = source.indexOf('rateLimits.setMiniMaxConfigResolver(() => {') + const inactiveResolverIndex = source.indexOf( + 'rateLimits.setInactiveClaudeAccountsResolver(() => {', + miniMaxResolverIndex + ) + const inactiveResolverEnd = source.indexOf( + 'rateLimits.setInactiveCodexAccountsResolver(() => {', + inactiveResolverIndex + ) + const inactiveResolver = source.slice(inactiveResolverIndex, inactiveResolverEnd) + + expect(source.slice(miniMaxResolverIndex, inactiveResolverIndex)).toContain( + 'getAccountRuntimeCoordinationStartupCapability().readMiniMaxSessionCookie()' + ) + expect( + inactiveResolver.match( + /getAccountRuntimeCoordinationStartupCapability\(\)\.normalizeClaudeRuntimeSelection/g + ) + ).toHaveLength(2) + }) + + it('keeps unrelated path, WSL, and restart-preservation leaves eager', () => { + expect(findImport('../shared/cross-platform-path')).toBeDefined() + expect(findImport('./wsl')).toBeDefined() + expect(findImport('./agent-auth-restart-preservation')).toBeDefined() + + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const preReadySource = source.slice(0, readyIndex) + for (const value of [ + 'readMiniMaxSessionCookie(', + 'getInitialClaudeRateLimitTarget(', + 'getInitialCodexRateLimitTarget(', + 'createAccountRuntimeTargetSettingsSync(', + 'normalizeClaudeRuntimeSelection(', + 'attachClaudeLivePtyPersistence(', + 'onLiveClaudePtysDrained(', + 'seedLiveClaudePtysFromPersistence(' + ]) { + expect(preReadySource).not.toContain(value) + } + }) +}) diff --git a/src/main/startup/account-runtime-coordination-startup-capability.test.ts b/src/main/startup/account-runtime-coordination-startup-capability.test.ts new file mode 100644 index 00000000000..9f2c4a482f0 --- /dev/null +++ b/src/main/startup/account-runtime-coordination-startup-capability.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +const accountRuntimeMocks = vi.hoisted(() => ({ + attachClaudeLivePtyPersistence: vi.fn(), + createAccountRuntimeTargetSettingsSync: vi.fn(), + getInitialClaudeRateLimitTarget: vi.fn(), + getInitialCodexRateLimitTarget: vi.fn(), + normalizeClaudeRuntimeSelection: vi.fn(), + onLiveClaudePtysDrained: vi.fn(), + readMiniMaxSessionCookie: vi.fn(), + seedLiveClaudePtysFromPersistence: vi.fn() +})) + +vi.mock('../claude-accounts/live-pty-gate', () => ({ + attachClaudeLivePtyPersistence: accountRuntimeMocks.attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained: accountRuntimeMocks.onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence: accountRuntimeMocks.seedLiveClaudePtysFromPersistence +})) +vi.mock('../claude-accounts/runtime-selection', () => ({ + normalizeClaudeRuntimeSelection: accountRuntimeMocks.normalizeClaudeRuntimeSelection +})) +vi.mock('../minimax/minimax-cookie-store', () => ({ + readMiniMaxSessionCookie: accountRuntimeMocks.readMiniMaxSessionCookie +})) +vi.mock('../rate-limits/account-runtime-target-sync', () => ({ + createAccountRuntimeTargetSettingsSync: accountRuntimeMocks.createAccountRuntimeTargetSettingsSync +})) +vi.mock('../rate-limits/claude-rate-limit-target', () => ({ + getInitialClaudeRateLimitTarget: accountRuntimeMocks.getInitialClaudeRateLimitTarget +})) +vi.mock('../rate-limits/codex-rate-limit-target', () => ({ + getInitialCodexRateLimitTarget: accountRuntimeMocks.getInitialCodexRateLimitTarget +})) + +import { createAccountRuntimeCoordinationStartupCapability } from './account-runtime-coordination-startup-capability' + +describe('account-runtime coordination startup capability', () => { + it('returns every original function identity', () => { + expect(createAccountRuntimeCoordinationStartupCapability()).toEqual(accountRuntimeMocks) + }) +}) diff --git a/src/main/startup/account-runtime-coordination-startup-capability.ts b/src/main/startup/account-runtime-coordination-startup-capability.ts new file mode 100644 index 00000000000..3c017d10a95 --- /dev/null +++ b/src/main/startup/account-runtime-coordination-startup-capability.ts @@ -0,0 +1,34 @@ +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' +import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' +import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' +import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' + +export type AccountRuntimeCoordinationStartupCapability = { + attachClaudeLivePtyPersistence: typeof attachClaudeLivePtyPersistence + createAccountRuntimeTargetSettingsSync: typeof createAccountRuntimeTargetSettingsSync + getInitialClaudeRateLimitTarget: typeof getInitialClaudeRateLimitTarget + getInitialCodexRateLimitTarget: typeof getInitialCodexRateLimitTarget + normalizeClaudeRuntimeSelection: typeof normalizeClaudeRuntimeSelection + onLiveClaudePtysDrained: typeof onLiveClaudePtysDrained + readMiniMaxSessionCookie: typeof readMiniMaxSessionCookie + seedLiveClaudePtysFromPersistence: typeof seedLiveClaudePtysFromPersistence +} + +export function createAccountRuntimeCoordinationStartupCapability(): AccountRuntimeCoordinationStartupCapability { + return { + attachClaudeLivePtyPersistence, + createAccountRuntimeTargetSettingsSync, + getInitialClaudeRateLimitTarget, + getInitialCodexRateLimitTarget, + normalizeClaudeRuntimeSelection, + onLiveClaudePtysDrained, + readMiniMaxSessionCookie, + seedLiveClaudePtysFromPersistence + } +} diff --git a/src/main/startup/account-runtime-coordination-startup-owner.test.ts b/src/main/startup/account-runtime-coordination-startup-owner.test.ts new file mode 100644 index 00000000000..4faf7ff8e58 --- /dev/null +++ b/src/main/startup/account-runtime-coordination-startup-owner.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('account-runtime coordination startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation', async () => { + const { getAccountRuntimeCoordinationStartupCapability } = + await import('./account-runtime-coordination-startup-owner') + + expect(() => getAccountRuntimeCoordinationStartupCapability()).toThrow( + 'Account-runtime coordination capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { + getAccountRuntimeCoordinationStartupCapability, + installAccountRuntimeCoordinationStartupCapability + } = await import('./account-runtime-coordination-startup-owner') + const capability = { + attachClaudeLivePtyPersistence: vi.fn(), + createAccountRuntimeTargetSettingsSync: vi.fn(), + getInitialClaudeRateLimitTarget: vi.fn(), + getInitialCodexRateLimitTarget: vi.fn(), + normalizeClaudeRuntimeSelection: vi.fn(), + onLiveClaudePtysDrained: vi.fn(), + readMiniMaxSessionCookie: vi.fn(), + seedLiveClaudePtysFromPersistence: vi.fn() + } + + installAccountRuntimeCoordinationStartupCapability(capability as never) + + expect(getAccountRuntimeCoordinationStartupCapability()).toBe(capability) + }) +}) diff --git a/src/main/startup/account-runtime-coordination-startup-owner.ts b/src/main/startup/account-runtime-coordination-startup-owner.ts new file mode 100644 index 00000000000..acfa69b9595 --- /dev/null +++ b/src/main/startup/account-runtime-coordination-startup-owner.ts @@ -0,0 +1,16 @@ +import type { AccountRuntimeCoordinationStartupCapability } from './account-runtime-coordination-startup-capability' + +let capability: AccountRuntimeCoordinationStartupCapability | null = null + +export function installAccountRuntimeCoordinationStartupCapability( + nextCapability: AccountRuntimeCoordinationStartupCapability +): void { + capability = nextCapability +} + +export function getAccountRuntimeCoordinationStartupCapability(): AccountRuntimeCoordinationStartupCapability { + if (!capability) { + throw new Error('Account-runtime coordination capability must be initialized before use') + } + return capability +} diff --git a/src/main/startup/account-services-startup-boundary.test.ts b/src/main/startup/account-services-startup-boundary.test.ts new file mode 100644 index 00000000000..80ef60f6c39 --- /dev/null +++ b/src/main/startup/account-services-startup-boundary.test.ts @@ -0,0 +1,148 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/account-services-startup-capability')" +const capabilityFactory = 'const accountServices = createAccountServicesStartupCapability(store, {' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('account services startup boundary', () => { + it('keeps all five implementations out of the eager main graph', () => { + const serviceModules = [ + './rate-limits/service', + './codex-accounts/runtime-home-service', + './codex-accounts/service', + './claude-accounts/runtime-auth-service', + './claude-accounts/service' + ] + + for (const moduleSpecifier of serviceModules) { + expect(findImport(moduleSpecifier)?.importClause?.isTypeOnly).toBe(true) + } + expect(source).not.toMatch( + /new (RateLimitService|CodexRuntimeHomeService|CodexAccountService|ClaudeRuntimeAuthService|ClaudeAccountService)\(/ + ) + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('keeps Codex configuration and migration policy in the composition root', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const configureIndex = source.indexOf('configureCodexRuntimeHome: (runtimeHome) => {') + const laneGateIndex = source.indexOf('runtimeHome.setRealHomeLaneGate(', configureIndex) + const sweepIndex = source.indexOf('setSystemCodexHomeHookSweepSuppressed(', laneGateIndex) + const schedulerIndex = source.indexOf('createCodexSessionMigrationScheduler({', sweepIndex) + const lifecycleIndex = source.indexOf( + 'onHostSystemDefaultSelected: codexSessionMigration.requestRun', + schedulerIndex + ) + const initialRunIndex = source.indexOf( + 'afterCodexAccountCreated: codexSessionMigration.scheduleInitialRun', + lifecycleIndex + ) + + expect(configureIndex).toBeGreaterThan(factoryIndex) + expect(laneGateIndex).toBeGreaterThan(configureIndex) + expect(sweepIndex).toBeGreaterThan(laneGateIndex) + expect(schedulerIndex).toBeGreaterThan(sweepIndex) + expect(lifecycleIndex).toBeGreaterThan(schedulerIndex) + expect(initialRunIndex).toBeGreaterThan(lifecycleIndex) + }) + + it('assigns every returned singleton before rate-limit and runtime wiring', () => { + const openCodeUsageIndex = source.indexOf( + 'openCodeUsage = await createOpenCodeUsageStoreStartupCapability(store)' + ) + const importIndex = source.indexOf(capabilityImport, openCodeUsageIndex) + const factoryIndex = source.indexOf(capabilityFactory) + const assignments = [ + 'rateLimits = accountServices.rateLimits', + 'codexRuntimeHome = accountServices.codexRuntimeHome', + 'codexAccounts = accountServices.codexAccounts', + 'claudeRuntimeAuth = accountServices.claudeRuntimeAuth', + 'claudeAccounts = accountServices.claudeAccounts' + ] + let previousIndex = factoryIndex + for (const assignment of assignments) { + const assignmentIndex = source.indexOf(assignment, previousIndex) + expect(assignmentIndex).toBeGreaterThan(previousIndex) + previousIndex = assignmentIndex + } + + const resolverIndex = source.indexOf('rateLimits.setCodexHomePathResolver(', previousIndex) + const runtimeIndex = source.indexOf( + 'runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })', + resolverIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThan(openCodeUsageIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(resolverIndex).toBeGreaterThan(previousIndex) + expect(runtimeIndex).toBeGreaterThan(resolverIndex) + expect(initializedIndex).toBeGreaterThan(runtimeIndex) + }) + + it('keeps main-window guards, core IPC, PTY, and auth consumers on the same globals', () => { + const guardMessages = [ + 'Rate limit service must be initialized before opening the main window', + 'Codex account service must be initialized before opening the main window', + 'Codex runtime home service must be initialized before opening the main window', + 'Claude account service must be initialized before opening the main window', + 'Claude runtime auth service must be initialized before opening the main window' + ] + + for (const message of guardMessages) { + expect(source).toContain(message) + } + const coreHandlersIndex = source.indexOf('registerCoreHandlers(') + const coreCodexIndex = source.indexOf('codexAccounts,', coreHandlersIndex) + const coreClaudeIndex = source.indexOf('claudeAccounts,', coreCodexIndex) + const coreRateLimitsIndex = source.indexOf('rateLimits,', coreClaudeIndex) + + expect(coreHandlersIndex).toBeGreaterThanOrEqual(0) + expect(coreCodexIndex).toBeGreaterThan(coreHandlersIndex) + expect(coreClaudeIndex).toBeGreaterThan(coreCodexIndex) + expect(coreRateLimitsIndex).toBeGreaterThan(coreClaudeIndex) + expect(source).toContain('prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch') + expect(source).toContain( + 'prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)' + ) + expect(source).toContain( + 'await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })' + ) + expect(source).toContain('rateLimits.attach(window)') + expect(source).toContain('rateLimits.start({ fetchImmediately: false })') + }) + + it('keeps live usage, inactive accounts, serve preparation, and quit behavior in index', () => { + const liveStatusIndex = source.indexOf('agentHookServer.setClaudeStatusLineListener(') + const inactiveClaudeIndex = source.indexOf( + 'rateLimits.setInactiveClaudeAccountsResolver(', + liveStatusIndex + ) + const inactiveCodexIndex = source.indexOf( + 'rateLimits.setInactiveCodexAccountsResolver(', + inactiveClaudeIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', inactiveCodexIndex) + const headlessPtyIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + const stopIndex = source.indexOf('rateLimits?.stop()') + + expect(liveStatusIndex).toBeGreaterThanOrEqual(0) + expect(inactiveClaudeIndex).toBeGreaterThan(liveStatusIndex) + expect(inactiveCodexIndex).toBeGreaterThan(inactiveClaudeIndex) + expect(headlessPtyIndex).toBeGreaterThan(serveIndex) + expect(stopIndex).toBeGreaterThan(headlessPtyIndex) + }) +}) diff --git a/src/main/startup/account-services-startup-capability.test.ts b/src/main/startup/account-services-startup-capability.test.ts new file mode 100644 index 00000000000..ba47c1a1ec1 --- /dev/null +++ b/src/main/startup/account-services-startup-capability.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + events: [] as string[], + rateLimitConstructor: vi.fn(), + codexRuntimeHomeConstructor: vi.fn(), + codexAccountConstructor: vi.fn(), + claudeRuntimeAuthConstructor: vi.fn(), + claudeAccountConstructor: vi.fn() +})) + +vi.mock('../rate-limits/service', () => ({ + RateLimitService: class { + constructor() { + serviceMocks.events.push('rate-limits') + serviceMocks.rateLimitConstructor(this) + } + } +})) + +vi.mock('../codex-accounts/runtime-home-service', () => ({ + CodexRuntimeHomeService: class { + constructor(store: unknown) { + serviceMocks.events.push('codex-runtime-home') + serviceMocks.codexRuntimeHomeConstructor(this, store) + } + } +})) + +vi.mock('../codex-accounts/service', () => ({ + CodexAccountService: class { + constructor(store: unknown, rateLimits: unknown, runtimeHome: unknown, lifecycle: unknown) { + serviceMocks.events.push('codex-account') + serviceMocks.codexAccountConstructor(this, store, rateLimits, runtimeHome, lifecycle) + } + } +})) + +vi.mock('../claude-accounts/runtime-auth-service', () => ({ + ClaudeRuntimeAuthService: class { + constructor(store: unknown) { + serviceMocks.events.push('claude-runtime-auth') + serviceMocks.claudeRuntimeAuthConstructor(this, store) + } + } +})) + +vi.mock('../claude-accounts/service', () => ({ + ClaudeAccountService: class { + constructor(store: unknown, rateLimits: unknown, runtimeAuth: unknown) { + serviceMocks.events.push('claude-account') + serviceMocks.claudeAccountConstructor(this, store, rateLimits, runtimeAuth) + } + } +})) + +import type { Store } from '../persistence' +import { createAccountServicesStartupCapability } from './account-services-startup-capability' + +describe('account services startup capability', () => { + beforeEach(() => { + serviceMocks.events.length = 0 + vi.clearAllMocks() + }) + + it('constructs, configures, and schedules in the exact startup order', () => { + const store = {} as Store + const lifecycle = { onHostSystemDefaultSelected: vi.fn() } + const afterCodexAccountCreated = vi.fn(() => { + serviceMocks.events.push('after-codex-account') + }) + const configureCodexRuntimeHome = vi.fn((_runtimeHome: unknown) => { + serviceMocks.events.push('configure-codex-runtime-home') + return { codexAccountLifecycle: lifecycle, afterCodexAccountCreated } + }) + + const services = createAccountServicesStartupCapability(store, { + configureCodexRuntimeHome + }) + + expect(serviceMocks.events).toEqual([ + 'rate-limits', + 'codex-runtime-home', + 'configure-codex-runtime-home', + 'codex-account', + 'after-codex-account', + 'claude-runtime-auth', + 'claude-account' + ]) + expect(configureCodexRuntimeHome).toHaveBeenCalledOnce() + expect(configureCodexRuntimeHome).toHaveBeenCalledWith(services.codexRuntimeHome) + expect(afterCodexAccountCreated).toHaveBeenCalledOnce() + }) + + it('preserves Store and dependency identity across all five live services', () => { + const store = {} as Store + const lifecycle = { onHostSystemDefaultSelected: vi.fn() } + + const services = createAccountServicesStartupCapability(store, { + configureCodexRuntimeHome: () => ({ + codexAccountLifecycle: lifecycle, + afterCodexAccountCreated: vi.fn() + }) + }) + + expect(serviceMocks.rateLimitConstructor).toHaveBeenCalledWith(services.rateLimits) + expect(serviceMocks.codexRuntimeHomeConstructor).toHaveBeenCalledWith( + services.codexRuntimeHome, + store + ) + expect(serviceMocks.codexAccountConstructor).toHaveBeenCalledWith( + services.codexAccounts, + store, + services.rateLimits, + services.codexRuntimeHome, + lifecycle + ) + expect(serviceMocks.claudeRuntimeAuthConstructor).toHaveBeenCalledWith( + services.claudeRuntimeAuth, + store + ) + expect(serviceMocks.claudeAccountConstructor).toHaveBeenCalledWith( + services.claudeAccounts, + store, + services.rateLimits, + services.claudeRuntimeAuth + ) + }) +}) diff --git a/src/main/startup/account-services-startup-capability.ts b/src/main/startup/account-services-startup-capability.ts new file mode 100644 index 00000000000..e563659ba6c --- /dev/null +++ b/src/main/startup/account-services-startup-capability.ts @@ -0,0 +1,51 @@ +import { ClaudeAccountService } from '../claude-accounts/service' +import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import { CodexAccountService, type CodexAccountServiceLifecycle } from '../codex-accounts/service' +import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import type { Store } from '../persistence' +import { RateLimitService } from '../rate-limits/service' + +type CodexRuntimeHomeStartupConfiguration = { + codexAccountLifecycle: CodexAccountServiceLifecycle + afterCodexAccountCreated: () => void +} + +type AccountServicesStartupOptions = { + configureCodexRuntimeHome: ( + runtimeHome: CodexRuntimeHomeService + ) => CodexRuntimeHomeStartupConfiguration +} + +export type AccountServicesStartupCapability = { + rateLimits: RateLimitService + codexRuntimeHome: CodexRuntimeHomeService + codexAccounts: CodexAccountService + claudeRuntimeAuth: ClaudeRuntimeAuthService + claudeAccounts: ClaudeAccountService +} + +export function createAccountServicesStartupCapability( + store: Store, + options: AccountServicesStartupOptions +): AccountServicesStartupCapability { + const rateLimits = new RateLimitService() + const codexRuntimeHome = new CodexRuntimeHomeService(store) + const codexConfiguration = options.configureCodexRuntimeHome(codexRuntimeHome) + const codexAccounts = new CodexAccountService( + store, + rateLimits, + codexRuntimeHome, + codexConfiguration.codexAccountLifecycle + ) + codexConfiguration.afterCodexAccountCreated() + const claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + const claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) + + return { + rateLimits, + codexRuntimeHome, + codexAccounts, + claudeRuntimeAuth, + claudeAccounts + } +} diff --git a/src/main/startup/agent-awake-startup-boundary.test.ts b/src/main/startup/agent-awake-startup-boundary.test.ts new file mode 100644 index 00000000000..e72f11d054b --- /dev/null +++ b/src/main/startup/agent-awake-startup-boundary.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/agent-awake-startup-capability')" +const capabilityFactory = 'agentAwakeService = await createAgentAwakeStartupCapability()' + +describe('agent awake startup boundary', () => { + it('keeps the service implementation out of the eager main graph', () => { + const serviceImport = sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => + (declaration.moduleSpecifier as ts.StringLiteral).text === './agent-awake-service' + ) + + expect(serviceImport?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new AgentAwakeService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('initializes the service before subscriptions and observable readiness', () => { + const importIndex = source.indexOf(capabilityImport) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const settingsIndex = source.indexOf( + 'agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)', + factoryIndex + ) + const subscriptionIndex = source.indexOf( + 'agentHookServer.subscribeStatusChanges', + settingsIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(settingsIndex).toBeGreaterThan(factoryIndex) + expect(subscriptionIndex).toBeGreaterThan(settingsIndex) + expect(initializedIndex).toBeGreaterThan(subscriptionIndex) + }) +}) diff --git a/src/main/startup/agent-awake-startup-capability.test.ts b/src/main/startup/agent-awake-startup-capability.test.ts new file mode 100644 index 00000000000..f3f93d182ce --- /dev/null +++ b/src/main/startup/agent-awake-startup-capability.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + powerMonitor: { + on: vi.fn(), + off: vi.fn() + }, + powerSaveBlocker: { + start: vi.fn(), + stop: vi.fn(), + isStarted: vi.fn() + } +})) + +import { AgentAwakeService } from '../agent-awake-service' +import { createAgentAwakeStartupCapability } from './agent-awake-startup-capability' + +describe('agent awake startup capability', () => { + it('returns the live service after initializing runtime statuses as empty', async () => { + const setStatuses = vi.spyOn(AgentAwakeService.prototype, 'setStatuses') + + const service = await createAgentAwakeStartupCapability() + + expect(service).toBeInstanceOf(AgentAwakeService) + expect(setStatuses).toHaveBeenCalledOnce() + expect(setStatuses).toHaveBeenCalledWith([]) + service.dispose() + }) +}) diff --git a/src/main/startup/agent-awake-startup-capability.ts b/src/main/startup/agent-awake-startup-capability.ts new file mode 100644 index 00000000000..dad25799a9b --- /dev/null +++ b/src/main/startup/agent-awake-startup-capability.ts @@ -0,0 +1,8 @@ +import { AgentAwakeService } from '../agent-awake-service' + +export async function createAgentAwakeStartupCapability(): Promise { + const service = new AgentAwakeService() + // Why: disk-hydrated rows are UI continuity; only this runtime's hook events prevent sleep. + service.setStatuses([]) + return service +} diff --git a/src/main/startup/agent-browser-startup-boundary.test.ts b/src/main/startup/agent-browser-startup-boundary.test.ts new file mode 100644 index 00000000000..3c73b17e1f4 --- /dev/null +++ b/src/main/startup/agent-browser-startup-boundary.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/agent-browser-startup-capability')" + +describe('agent browser startup boundary', () => { + it('keeps the bridge implementation out of the eager main graph', () => { + const staticImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .map((declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text) + + expect(staticImports).not.toContain('./browser/agent-browser-bridge') + expect(source).not.toContain('new AgentBrowserBridge(') + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('attaches the bridge before services are observable', () => { + const importIndex = source.indexOf(capabilityImport) + const attachIndex = source.indexOf( + 'attachAgentBrowserStartupCapability(runtimeService, browserManager)', + importIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(attachIndex).toBeGreaterThan(importIndex) + expect(initializedIndex).toBeGreaterThan(attachIndex) + }) +}) diff --git a/src/main/startup/agent-browser-startup-capability.test.ts b/src/main/startup/agent-browser-startup-capability.test.ts new file mode 100644 index 00000000000..e40108a1736 --- /dev/null +++ b/src/main/startup/agent-browser-startup-capability.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getAppPath: vi.fn(() => '/project'), + getPath: vi.fn(() => '/app') + }, + webContents: { + fromId: vi.fn(() => null) + } +})) + +import type { BrowserManager } from '../browser/browser-manager' +import { attachAgentBrowserStartupCapability } from './agent-browser-startup-capability' + +describe('agent browser startup capability', () => { + it('attaches one bridge and forwards tab changes to mobile sessions', () => { + const browserManager = {} as BrowserManager + const setAgentBrowserBridge = vi.fn() + const notifyMobileSessionTabsChanged = vi.fn() + + const bridge = attachAgentBrowserStartupCapability( + { notifyMobileSessionTabsChanged, setAgentBrowserBridge }, + browserManager + ) + bridge.setActiveTab(42, 'worktree-1') + + expect(setAgentBrowserBridge).toHaveBeenCalledOnce() + expect(setAgentBrowserBridge).toHaveBeenCalledWith(bridge) + expect(notifyMobileSessionTabsChanged).toHaveBeenCalledOnce() + expect(notifyMobileSessionTabsChanged).toHaveBeenCalledWith('worktree-1') + }) +}) diff --git a/src/main/startup/agent-browser-startup-capability.ts b/src/main/startup/agent-browser-startup-capability.ts new file mode 100644 index 00000000000..641a4fdabab --- /dev/null +++ b/src/main/startup/agent-browser-startup-capability.ts @@ -0,0 +1,19 @@ +import { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { BrowserManager } from '../browser/browser-manager' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type AgentBrowserStartupRuntime = Pick< + OrcaRuntimeService, + 'notifyMobileSessionTabsChanged' | 'setAgentBrowserBridge' +> + +export function attachAgentBrowserStartupCapability( + runtime: AgentBrowserStartupRuntime, + browserManager: BrowserManager +): AgentBrowserBridge { + const bridge = new AgentBrowserBridge(browserManager, { + onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + runtime.setAgentBrowserBridge(bridge) + return bridge +} diff --git a/src/main/startup/agent-hook-runtime-startup-boundary.test.ts b/src/main/startup/agent-hook-runtime-startup-boundary.test.ts new file mode 100644 index 00000000000..8a9c2169e8b --- /dev/null +++ b/src/main/startup/agent-hook-runtime-startup-boundary.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/agent-hook-runtime-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('agent-hook runtime startup boundary', () => { + it('removes every eager index value import behind one typed seam', () => { + for (const moduleSpecifier of [ + './agent-hooks/install-telemetry', + './agent-hooks/managed-agent-hook-controls', + './agent-hooks/server', + './agent-hooks/hook-provider-session-invalidation', + './agent-hooks/wsl-hook-relay-manager', + './agent-hooks/first-work-branch-rename', + './agent-hooks/branch-rename-failure-output', + './agent-hooks/first-work-folder-rename', + './agent-hooks/migration-unsupported-pty-state' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./startup/agent-hook-runtime-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)?.importClause?.isTypeOnly).toBe(true) + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after retained app-ready capabilities and before every consumer', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const desktopInstallIndex = source.indexOf( + 'installDesktopShellStartupCapability(desktopShell)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, desktopInstallIndex) + const factoryIndex = source.indexOf( + 'const agentHooks = createAgentHookRuntimeStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installAgentHookRuntimeStartupCapability(agentHooks)', + factoryIndex + ) + const browserManagerIndex = source.indexOf( + 'const browserManager = browserKernel.browserManager', + installIndex + ) + const storeIndex = source.indexOf('store = createStoreStartupCapability(', browserManagerIndex) + const runtimeIndex = source.indexOf('createOrcaRuntimeServiceStartupCapability(', storeIndex) + const terminalStartIndex = source.indexOf('startTerminalRuntimeStartupServices()', runtimeIndex) + + expect(importIndex).toBeGreaterThan(desktopInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(browserManagerIndex).toBeGreaterThan(installIndex) + expect(storeIndex).toBeGreaterThan(browserManagerIndex) + expect(runtimeIndex).toBeGreaterThan(storeIndex) + expect(terminalStartIndex).toBeGreaterThan(runtimeIndex) + }) + + it('preserves the hook-server start contract and runtime identity', () => { + const startupStart = source.indexOf('function startTerminalRuntimeStartupServices():') + const startupEnd = source.indexOf('\nfunction prepareCodexRuntimeHomeForLaunch', startupStart) + const startupBlock = source.slice(startupStart, startupEnd) + const runtimeStart = source.indexOf('createOrcaRuntimeServiceStartupCapability(') + const runtimeEnd = source.indexOf('\n runtime = runtimeService', runtimeStart) + const runtimeBlock = source.slice(runtimeStart, runtimeEnd) + + expect(startupBlock).toContain('getAgentHookRuntimeStartupCapability()') + expect(startupBlock).toContain('if (!isAgentStatusHooksEnabled(store?.getSettings()))') + expect(startupBlock).toContain('await agentHookServer.start({') + expect(startupBlock).toContain("env: app.isPackaged ? 'production' : 'development'") + expect(startupBlock).toContain("userDataPath: app.getPath('userData')") + expect(startupBlock).toContain('endpointNamespace: devAgentHookEndpointNamespace') + expect(runtimeBlock).toContain('agentHooks.agentHookServer.ingestTerminalStatus(event)') + expect(runtimeBlock).toContain('agentHooks.agentHookServer.getStatusSnapshot()') + expect(runtimeBlock).toContain('agentHooks.agentHookServer.buildPtyEnv()') + }) + + it('preserves first-work, migration, managed-install, and listener ordering', () => { + const renameStart = source.indexOf('function maybeAutoRenameBranchOnFirstWorkFromHook(') + const renameEnd = source.indexOf('\nconst devInstanceIdentity', renameStart) + const renameBlock = source.slice(renameStart, renameEnd) + const windowStart = source.indexOf('function openMainWindow(): BrowserWindow') + const windowEnd = source.indexOf('\nfunction sendOpenFeatureTour', windowStart) + const windowBlock = source.slice(windowStart, windowEnd) + const managedIndex = source.indexOf('if (shouldInstallManagedHooks(is.dev))') + const childProcessIndex = source.indexOf("app.on('child-process-gone'", managedIndex) + + expect(renameBlock).toContain('maybeAutoRenameBranchOnFirstWork(') + expect(renameBlock).toContain('rememberBranchRenameFailureOutput(worktreeId, null)') + expect(renameBlock).toContain('renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, {') + expect(renameBlock).toContain('moveWorktree') + expect(windowBlock).toContain('agentHookServer.setListener(') + expect(windowBlock).toContain('agentHookServer.setPaneStatusClearListener(') + expect(windowBlock).toContain('setMigrationUnsupportedPtyListener(') + expect(source.slice(managedIndex, childProcessIndex)).toContain( + 'agentHooks.runManagedHookInstallers(agentHooks.MANAGED_AGENT_HOOK_INSTALLERS)' + ) + expect(source.slice(managedIndex, childProcessIndex)).toContain( + 'agentHooks.removeManagedAgentHooks()' + ) + }) + + it('keeps early quit safe and exact post-install teardown identities', () => { + const willQuitIndex = source.indexOf("app.on('will-quit', (e) => {") + const willQuitBlock = source.slice(willQuitIndex) + + expect(willQuitBlock).toContain( + 'const agentHooks = getAgentHookRuntimeStartupCapabilityIfInstalled()' + ) + expect(willQuitBlock).toContain('agentHooks?.agentHookServer.stop()') + expect(willQuitBlock).toContain('agentHooks?.wslHookRelayManager.disposeAll()') + }) +}) diff --git a/src/main/startup/agent-hook-runtime-startup-capability.test.ts b/src/main/startup/agent-hook-runtime-startup-capability.test.ts new file mode 100644 index 00000000000..6aa9fca7437 --- /dev/null +++ b/src/main/startup/agent-hook-runtime-startup-capability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' + +const agentHookMocks = vi.hoisted(() => ({ + agentHookServer: { start: vi.fn(), stop: vi.fn() }, + createHookProviderSessionInvalidator: vi.fn(), + isAgentStatusHooksEnabled: vi.fn(), + MANAGED_AGENT_HOOK_INSTALLERS: [['codex', vi.fn()]] as const, + maybeAutoRenameBranchOnFirstWork: vi.fn(), + rememberBranchRenameFailureOutput: vi.fn(), + removeManagedAgentHooks: vi.fn(), + renameWorktreeFolderOnFirstWork: vi.fn(), + runManagedHookInstallers: vi.fn(), + setMigrationUnsupportedPtyListener: vi.fn(), + wslHookRelayManager: { disposeAll: vi.fn() } +})) + +vi.mock('../agent-hooks/branch-rename-failure-output', () => ({ + rememberBranchRenameFailureOutput: agentHookMocks.rememberBranchRenameFailureOutput +})) +vi.mock('../agent-hooks/first-work-branch-rename', () => ({ + maybeAutoRenameBranchOnFirstWork: agentHookMocks.maybeAutoRenameBranchOnFirstWork +})) +vi.mock('../agent-hooks/first-work-folder-rename', () => ({ + renameWorktreeFolderOnFirstWork: agentHookMocks.renameWorktreeFolderOnFirstWork +})) +vi.mock('../agent-hooks/hook-provider-session-invalidation', () => ({ + createHookProviderSessionInvalidator: agentHookMocks.createHookProviderSessionInvalidator +})) +vi.mock('../agent-hooks/install-telemetry', () => ({ + runManagedHookInstallers: agentHookMocks.runManagedHookInstallers +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: agentHookMocks.isAgentStatusHooksEnabled, + MANAGED_AGENT_HOOK_INSTALLERS: agentHookMocks.MANAGED_AGENT_HOOK_INSTALLERS, + removeManagedAgentHooks: agentHookMocks.removeManagedAgentHooks +})) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + setMigrationUnsupportedPtyListener: agentHookMocks.setMigrationUnsupportedPtyListener +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: agentHookMocks.agentHookServer +})) +vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: agentHookMocks.wslHookRelayManager +})) + +import { createAgentHookRuntimeStartupCapability } from './agent-hook-runtime-startup-capability' + +describe('agent-hook runtime startup capability', () => { + it('returns every original singleton, function, and constant identity', () => { + expect(createAgentHookRuntimeStartupCapability()).toEqual(agentHookMocks) + }) +}) diff --git a/src/main/startup/agent-hook-runtime-startup-capability.ts b/src/main/startup/agent-hook-runtime-startup-capability.ts new file mode 100644 index 00000000000..360d431a45e --- /dev/null +++ b/src/main/startup/agent-hook-runtime-startup-capability.ts @@ -0,0 +1,45 @@ +import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' +import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' +import { runManagedHookInstallers } from '../agent-hooks/install-telemetry' +import { + isAgentStatusHooksEnabled, + MANAGED_AGENT_HOOK_INSTALLERS, + removeManagedAgentHooks +} from '../agent-hooks/managed-agent-hook-controls' +import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' +import { agentHookServer } from '../agent-hooks/server' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' + +export type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' + +export type AgentHookRuntimeStartupCapability = { + agentHookServer: typeof agentHookServer + createHookProviderSessionInvalidator: typeof createHookProviderSessionInvalidator + isAgentStatusHooksEnabled: typeof isAgentStatusHooksEnabled + MANAGED_AGENT_HOOK_INSTALLERS: typeof MANAGED_AGENT_HOOK_INSTALLERS + maybeAutoRenameBranchOnFirstWork: typeof maybeAutoRenameBranchOnFirstWork + rememberBranchRenameFailureOutput: typeof rememberBranchRenameFailureOutput + removeManagedAgentHooks: typeof removeManagedAgentHooks + renameWorktreeFolderOnFirstWork: typeof renameWorktreeFolderOnFirstWork + runManagedHookInstallers: typeof runManagedHookInstallers + setMigrationUnsupportedPtyListener: typeof setMigrationUnsupportedPtyListener + wslHookRelayManager: typeof wslHookRelayManager +} + +export function createAgentHookRuntimeStartupCapability(): AgentHookRuntimeStartupCapability { + return { + agentHookServer, + createHookProviderSessionInvalidator, + isAgentStatusHooksEnabled, + MANAGED_AGENT_HOOK_INSTALLERS, + maybeAutoRenameBranchOnFirstWork, + rememberBranchRenameFailureOutput, + removeManagedAgentHooks, + renameWorktreeFolderOnFirstWork, + runManagedHookInstallers, + setMigrationUnsupportedPtyListener, + wslHookRelayManager + } +} diff --git a/src/main/startup/agent-hook-runtime-startup-owner.test.ts b/src/main/startup/agent-hook-runtime-startup-owner.test.ts new file mode 100644 index 00000000000..324b00cfce9 --- /dev/null +++ b/src/main/startup/agent-hook-runtime-startup-owner.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('agent-hook runtime startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation while optional teardown reads null', async () => { + const { + getAgentHookRuntimeStartupCapability, + getAgentHookRuntimeStartupCapabilityIfInstalled + } = await import('./agent-hook-runtime-startup-owner') + + expect(getAgentHookRuntimeStartupCapabilityIfInstalled()).toBeNull() + expect(() => getAgentHookRuntimeStartupCapability()).toThrow( + 'Agent-hook runtime capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { + getAgentHookRuntimeStartupCapability, + getAgentHookRuntimeStartupCapabilityIfInstalled, + installAgentHookRuntimeStartupCapability + } = await import('./agent-hook-runtime-startup-owner') + const capability = { + agentHookServer: {}, + createHookProviderSessionInvalidator: vi.fn(), + isAgentStatusHooksEnabled: vi.fn(), + MANAGED_AGENT_HOOK_INSTALLERS: [], + maybeAutoRenameBranchOnFirstWork: vi.fn(), + rememberBranchRenameFailureOutput: vi.fn(), + removeManagedAgentHooks: vi.fn(), + renameWorktreeFolderOnFirstWork: vi.fn(), + runManagedHookInstallers: vi.fn(), + setMigrationUnsupportedPtyListener: vi.fn(), + wslHookRelayManager: {} + } + + installAgentHookRuntimeStartupCapability(capability as never) + + expect(getAgentHookRuntimeStartupCapability()).toBe(capability) + expect(getAgentHookRuntimeStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/agent-hook-runtime-startup-owner.ts b/src/main/startup/agent-hook-runtime-startup-owner.ts new file mode 100644 index 00000000000..9938d4e5129 --- /dev/null +++ b/src/main/startup/agent-hook-runtime-startup-owner.ts @@ -0,0 +1,20 @@ +import type { AgentHookRuntimeStartupCapability } from './agent-hook-runtime-startup-capability' + +let capability: AgentHookRuntimeStartupCapability | null = null + +export function installAgentHookRuntimeStartupCapability( + nextCapability: AgentHookRuntimeStartupCapability +): void { + capability = nextCapability +} + +export function getAgentHookRuntimeStartupCapability(): AgentHookRuntimeStartupCapability { + if (!capability) { + throw new Error('Agent-hook runtime capability must be initialized before use') + } + return capability +} + +export function getAgentHookRuntimeStartupCapabilityIfInstalled(): AgentHookRuntimeStartupCapability | null { + return capability +} diff --git a/src/main/startup/automation-service-startup-boundary.test.ts b/src/main/startup/automation-service-startup-boundary.test.ts new file mode 100644 index 00000000000..ba3ad387ba8 --- /dev/null +++ b/src/main/startup/automation-service-startup-boundary.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/automation-service-startup-capability')" +const capabilityFactory = 'automations = await createAutomationServiceStartupCapability(store, {' + +describe('automation service startup boundary', () => { + it('keeps the service implementation out of the eager main graph', () => { + const serviceImport = sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => + (declaration.moduleSpecifier as ts.StringLiteral).text === './automations/service' + ) + + expect(serviceImport?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new AutomationService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('attaches the live instance before readiness without moving startup consumers', () => { + const importIndex = source.indexOf(capabilityImport) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const attachmentIndex = source.indexOf('runtimeService.setAutomationService(automations)') + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(attachmentIndex).toBeGreaterThan(factoryIndex) + expect(initializedIndex).toBeGreaterThan(attachmentIndex) + const coreHandlersIndex = source.indexOf('registerCoreHandlers(') + const webContentsIndex = source.indexOf('automations.setWebContents(window.webContents)') + expect(webContentsIndex).toBeGreaterThan(coreHandlersIndex) + expect(source.indexOf('automations.start()', webContentsIndex)).toBeGreaterThan( + webContentsIndex + ) + }) + + it('preserves scheduling inputs, dispatch routing, and committed lifetime', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const attachmentIndex = source.indexOf('runtimeService.setAutomationService', factoryIndex) + const constructionSource = source.slice(factoryIndex, attachmentIndex) + + expect(constructionSource).toContain('claudeUsage,') + expect(constructionSource).toContain('codexUsage,') + expect(constructionSource).toContain('allowRemoteHostScheduling: isServeMode') + expect(constructionSource).toContain('headlessDispatcher: isServeMode') + expect(constructionSource).toContain("automation.workspaceMode === 'new_per_run'") + expect(constructionSource).toContain('runtimeService.createManagedWorktree') + expect(constructionSource).toContain('runtimeService.launchAgentTerminal') + expect(constructionSource).toContain('runtimeService.waitForTerminal') + expect(constructionSource).toContain('runtimeService.readTerminal') + expect(constructionSource).toContain("status: 'completed' as const") + expect(constructionSource).toContain("status: 'dispatch_failed' as const") + expect( + source.indexOf('automations.start()', source.indexOf('if (serveOptions)')) + ).toBeGreaterThan( + source.indexOf('await runtimeRpc.start()', source.indexOf('if (serveOptions)')) + ) + expect(source.split('automations?.stop()')).toHaveLength(2) + expect(source.indexOf('automations?.stop()')).toBeGreaterThan( + source.indexOf("app.on('will-quit'") + ) + }) +}) diff --git a/src/main/startup/automation-service-startup-capability.test.ts b/src/main/startup/automation-service-startup-capability.test.ts new file mode 100644 index 00000000000..5c023e77389 --- /dev/null +++ b/src/main/startup/automation-service-startup-capability.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../automations/service', () => ({ + AutomationService: class { + constructor(store: unknown, options: unknown) { + serviceMocks.constructor(store, options) + } + } +})) + +import { AutomationService } from '../automations/service' +import { createAutomationServiceStartupCapability } from './automation-service-startup-capability' + +describe('automation service startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live service with the original inputs', async () => { + const store = { listAutomations: vi.fn() } + const options = { + claudeUsage: { getUsage: vi.fn() }, + codexUsage: { getUsage: vi.fn() }, + allowRemoteHostScheduling: true, + headlessDispatcher: vi.fn() + } + + const service = await createAutomationServiceStartupCapability(store as never, options as never) + + expect(serviceMocks.constructor).toHaveBeenCalledOnce() + expect(serviceMocks.constructor).toHaveBeenCalledWith(store, options) + expect(service).toBeInstanceOf(AutomationService) + }) +}) diff --git a/src/main/startup/automation-service-startup-capability.ts b/src/main/startup/automation-service-startup-capability.ts new file mode 100644 index 00000000000..d3bfcf8bc8c --- /dev/null +++ b/src/main/startup/automation-service-startup-capability.ts @@ -0,0 +1,10 @@ +import { AutomationService } from '../automations/service' + +type AutomationServiceParameters = ConstructorParameters + +export function createAutomationServiceStartupCapability( + store: AutomationServiceParameters[0], + options: NonNullable +): AutomationService { + return new AutomationService(store, options) +} diff --git a/src/main/startup/browser-kernel-startup-boundary.test.ts b/src/main/startup/browser-kernel-startup-boundary.test.ts new file mode 100644 index 00000000000..78f28231dfc --- /dev/null +++ b/src/main/startup/browser-kernel-startup-boundary.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const projectRoot = process.cwd() +const source = readFileSync(join(projectRoot, 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const createWindowSource = readFileSync( + join(projectRoot, 'src/main/window/createMainWindow.ts'), + 'utf8' +) +const attachWindowSource = readFileSync( + join(projectRoot, 'src/main/window/attach-main-window-services.ts'), + 'utf8' +) +const capabilityModule = './startup/browser-kernel-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = 'const browserKernel = createBrowserKernelStartupCapability()' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('browser kernel startup boundary', () => { + it('keeps index type-only and uses one dynamic aggregate capability', () => { + expect(findImport('./browser/browser-manager')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./browser/browser-session-startup')).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('removes browser kernel values from both eager window modules', () => { + for (const windowSource of [createWindowSource, attachWindowSource]) { + expect(windowSource).not.toContain("from '../browser/browser-manager'") + expect(windowSource).not.toContain("from '../browser/browser-session-registry'") + expect(windowSource).toContain("from '../browser/browser-kernel-window-dependencies'") + } + expect(createWindowSource).toContain('const { browserManager, isAllowedSessionPartition } =') + expect(attachWindowSource).toContain( + 'const { browserManager } = getBrowserKernelWindowDependencies()' + ) + }) + + it('loads at the earliest ready point before certificate registration and browser use', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const milestoneIndex = source.indexOf("logStartupMilestone('app-ready')", readyIndex) + const importIndex = source.indexOf(capabilityImport, milestoneIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const assignmentIndex = source.indexOf( + 'browserManagerForShutdown = browserManager', + factoryIndex + ) + const watchdogIndex = source.indexOf('installMainThreadHangWatchdog(', assignmentIndex) + const certificateIndex = source.indexOf("app.on(\n 'certificate-error'", watchdogIndex) + + expect(milestoneIndex).toBeGreaterThan(readyIndex) + expect(importIndex).toBeGreaterThan(milestoneIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(assignmentIndex).toBeGreaterThan(factoryIndex) + expect(watchdogIndex).toBeGreaterThan(assignmentIndex) + expect(certificateIndex).toBeGreaterThan(watchdogIndex) + }) + + it('preserves every certificate callback argument on the returned controller', () => { + const certificateIndex = source.indexOf("app.on(\n 'certificate-error'") + const certificateEnd = source.indexOf('electronApp.setAppUserModelId', certificateIndex) + const certificateBlock = source.slice(certificateIndex, certificateEnd) + + expect(certificateBlock).toContain( + 'browserKernel.browserCertificateTrustController.handleCertificateError({' + ) + for (const argument of [ + 'event,', + 'webContents,', + 'url,', + 'error,', + 'certificate,', + 'callback,', + 'isMainFrame' + ]) { + expect(certificateBlock).toContain(argument) + } + }) + + it('preserves session initialization inputs and downstream manager identity/order', () => { + const proxyIndex = source.indexOf('await applyElectronProxySettings(store.getSettings())') + const sessionIndex = source.indexOf( + 'browserKernel.initializeBrowserSessionsForApp({', + proxyIndex + ) + const systemResumeIndex = source.indexOf( + 'unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()', + sessionIndex + ) + const settingsIndex = source.indexOf('browserManager.setSettingsResolver(', systemResumeIndex) + const guestListenerIndex = source.indexOf( + 'browserManager.setBrowserGuestStateChangedListener(', + settingsIndex + ) + const agentBrowserIndex = source.indexOf( + 'attachAgentBrowserStartupCapability(runtimeService, browserManager)', + guestListenerIndex + ) + const offscreenIndex = source.indexOf( + 'attachOffscreenBrowserStartupCapability(runtime, browserManager)', + agentBrowserIndex + ) + + expect(sessionIndex).toBeGreaterThan(proxyIndex) + const sessionBlock = source.slice(sessionIndex, systemResumeIndex) + expect(sessionBlock).toContain('orcaProfileId: activeOrcaProfile.profile.id') + expect(sessionBlock).toContain('profileDirectory: activeOrcaProfile.profileDirectory') + expect(settingsIndex).toBeGreaterThan(systemResumeIndex) + expect(guestListenerIndex).toBeGreaterThan(settingsIndex) + expect(agentBrowserIndex).toBeGreaterThan(guestListenerIndex) + expect(offscreenIndex).toBeGreaterThan(agentBrowserIndex) + }) + + it('keeps headless and desktop readiness plus shutdown clearing after kernel setup', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const serveIndex = source.indexOf('if (serveOptions) {', factoryIndex) + const headlessGraphIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const headlessRpcIndex = source.indexOf('await runtimeRpc.start()', headlessGraphIndex) + const desktopWindowIndex = source.indexOf('Promise.resolve(openMainWindow())', headlessRpcIndex) + const shutdownIndex = source.indexOf("app.on('will-quit'") + const clearListenerIndex = source.indexOf( + 'browserManagerForShutdown?.setBrowserGuestStateChangedListener(null)', + shutdownIndex + ) + + expect(serveIndex).toBeGreaterThan(factoryIndex) + expect(headlessGraphIndex).toBeGreaterThan(serveIndex) + expect(headlessRpcIndex).toBeGreaterThan(headlessGraphIndex) + expect(desktopWindowIndex).toBeGreaterThan(headlessRpcIndex) + expect(clearListenerIndex).toBeGreaterThan(shutdownIndex) + }) +}) diff --git a/src/main/startup/browser-kernel-startup-capability.test.ts b/src/main/startup/browser-kernel-startup-capability.test.ts new file mode 100644 index 00000000000..b2abbfa979c --- /dev/null +++ b/src/main/startup/browser-kernel-startup-capability.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + browserManager: { marker: 'manager' }, + certificateTrustController: { marker: 'certificate-controller' }, + initializeBrowserSessionsForApp: vi.fn(), + isAllowedPartition: vi.fn(), + setWindowDependencies: vi.fn() +})) + +vi.mock('../browser/browser-manager', () => ({ + browserManager: browserMocks.browserManager, + browserCertificateTrustController: browserMocks.certificateTrustController +})) + +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { + isAllowedPartition: browserMocks.isAllowedPartition + } +})) + +vi.mock('../browser/browser-session-startup', () => ({ + initializeBrowserSessionsForApp: browserMocks.initializeBrowserSessionsForApp +})) + +vi.mock('../browser/browser-kernel-window-dependencies', () => ({ + setBrowserKernelWindowDependencies: browserMocks.setWindowDependencies +})) + +import { createBrowserKernelStartupCapability } from './browser-kernel-startup-capability' + +describe('browser kernel startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the exact manager, trust controller, and session initializer identities', () => { + const capability = createBrowserKernelStartupCapability() + + expect(capability.browserManager).toBe(browserMocks.browserManager) + expect(capability.browserCertificateTrustController).toBe( + browserMocks.certificateTrustController + ) + expect(capability.initializeBrowserSessionsForApp).toBe( + browserMocks.initializeBrowserSessionsForApp + ) + }) + + it('links window consumers to the same manager and session registry', () => { + createBrowserKernelStartupCapability() + + expect(browserMocks.setWindowDependencies).toHaveBeenCalledOnce() + const dependencies = browserMocks.setWindowDependencies.mock.calls[0]?.[0] + expect(dependencies.browserManager).toBe(browserMocks.browserManager) + + browserMocks.isAllowedPartition.mockReturnValue(true) + expect(dependencies.isAllowedSessionPartition('persist:profile')).toBe(true) + expect(browserMocks.isAllowedPartition).toHaveBeenCalledWith('persist:profile') + }) +}) diff --git a/src/main/startup/browser-kernel-startup-capability.ts b/src/main/startup/browser-kernel-startup-capability.ts new file mode 100644 index 00000000000..1250f59f152 --- /dev/null +++ b/src/main/startup/browser-kernel-startup-capability.ts @@ -0,0 +1,16 @@ +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { setBrowserKernelWindowDependencies } from '../browser/browser-kernel-window-dependencies' + +export function createBrowserKernelStartupCapability() { + setBrowserKernelWindowDependencies({ + browserManager, + isAllowedSessionPartition: (partition) => browserSessionRegistry.isAllowedPartition(partition) + }) + return { + browserCertificateTrustController, + browserManager, + initializeBrowserSessionsForApp + } +} diff --git a/src/main/startup/claude-usage-store-startup-boundary.test.ts b/src/main/startup/claude-usage-store-startup-boundary.test.ts new file mode 100644 index 00000000000..e343c50ca74 --- /dev/null +++ b/src/main/startup/claude-usage-store-startup-boundary.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/claude-usage-store-startup-capability')" +const capabilityFactory = 'claudeUsage = await createClaudeUsageStoreStartupCapability(store)' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('Claude usage store startup boundary', () => { + it('keeps only the path capture in the eager main graph', () => { + expect(findImport('./claude-usage/store')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./claude-usage/claude-usage-file-path')?.importClause?.isTypeOnly).toBe( + false + ) + expect(source).not.toContain('new ClaudeUsageStore(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('captures the path before app naming and constructs at the former services point', () => { + const dataPathIndex = source.indexOf('initDataPath()') + const usagePathIndex = source.indexOf('initClaudeUsagePath()', dataPathIndex) + const whenReadyIndex = source.indexOf('void app.whenReady()') + const appNameIndex = source.indexOf('app.setName(', whenReadyIndex) + const statsIndex = source.indexOf('stats = await createStatsCollectorStartupCapability()') + const importIndex = source.indexOf(capabilityImport, statsIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const codexUsageIndex = source.indexOf('codexUsage = new CodexUsageStore(store)', factoryIndex) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(dataPathIndex).toBeGreaterThanOrEqual(0) + expect(usagePathIndex).toBeGreaterThan(dataPathIndex) + expect(whenReadyIndex).toBeGreaterThan(usagePathIndex) + expect(appNameIndex).toBeGreaterThan(whenReadyIndex) + expect(importIndex).toBeGreaterThan(statsIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(codexUsageIndex).toBeGreaterThan(factoryIndex) + expect(initializedIndex).toBeGreaterThan(codexUsageIndex) + }) + + it('preserves readiness, core-handler identity, and automation injection order', () => { + expect(source).toContain( + "throw new Error('Claude usage store must be initialized before opening the main window')" + ) + const handlersIndex = source.indexOf('registerCoreHandlers(') + const factoryIndex = source.indexOf(capabilityFactory) + const automationImportIndex = source.indexOf( + "await import('./startup/automation-service-startup-capability')", + factoryIndex + ) + const automationFactoryIndex = source.indexOf( + 'automations = await createAutomationServiceStartupCapability(store, {', + automationImportIndex + ) + const automationClaudeUsageIndex = source.indexOf('claudeUsage,', automationFactoryIndex) + + expect(handlersIndex).toBeGreaterThanOrEqual(0) + expect(source.indexOf('claudeUsage,', handlersIndex)).toBeGreaterThan(handlersIndex) + expect(automationImportIndex).toBeGreaterThan(factoryIndex) + expect(automationFactoryIndex).toBeGreaterThan(automationImportIndex) + expect(automationClaudeUsageIndex).toBeGreaterThan(automationFactoryIndex) + expect(automationClaudeUsageIndex).toBeLessThan( + source.indexOf('codexUsage,', automationClaudeUsageIndex) + ) + expect(source.indexOf("logStartupMilestone('services-initialized')")).toBeGreaterThan( + automationClaudeUsageIndex + ) + }) +}) diff --git a/src/main/startup/claude-usage-store-startup-capability.test.ts b/src/main/startup/claude-usage-store-startup-capability.test.ts new file mode 100644 index 00000000000..d0db26a386c --- /dev/null +++ b/src/main/startup/claude-usage-store-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const storeMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../claude-usage/store', () => ({ + ClaudeUsageStore: class { + constructor(store: unknown) { + storeMocks.constructor(store) + } + } +})) + +import { ClaudeUsageStore } from '../claude-usage/store' +import { createClaudeUsageStoreStartupCapability } from './claude-usage-store-startup-capability' + +describe('Claude usage store startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live store with the original Store input', async () => { + const store = { getRepos: vi.fn() } + + const usageStore = await createClaudeUsageStoreStartupCapability(store as never) + + expect(storeMocks.constructor).toHaveBeenCalledOnce() + expect(storeMocks.constructor).toHaveBeenCalledWith(store) + expect(usageStore).toBeInstanceOf(ClaudeUsageStore) + }) +}) diff --git a/src/main/startup/claude-usage-store-startup-capability.ts b/src/main/startup/claude-usage-store-startup-capability.ts new file mode 100644 index 00000000000..79ce5e2d1d1 --- /dev/null +++ b/src/main/startup/claude-usage-store-startup-capability.ts @@ -0,0 +1,9 @@ +import { ClaudeUsageStore } from '../claude-usage/store' + +type ClaudeUsageStoreParameters = ConstructorParameters + +export function createClaudeUsageStoreStartupCapability( + store: ClaudeUsageStoreParameters[0] +): ClaudeUsageStore { + return new ClaudeUsageStore(store) +} diff --git a/src/main/startup/cli-wsl-startup-boundary.test.ts b/src/main/startup/cli-wsl-startup-boundary.test.ts new file mode 100644 index 00000000000..cedee3b60fd --- /dev/null +++ b/src/main/startup/cli-wsl-startup-boundary.test.ts @@ -0,0 +1,148 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const projectRoot = process.cwd() +const source = readFileSync(join(projectRoot, 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/cli-wsl-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = 'const cliWslStartupCapability = createCliWslStartupCapability()' + +function listProductionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + return listProductionTypeScriptFiles(entryPath) + } + return entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.spec.ts') + ? [entryPath] + : [] + }) +} + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('CLI and WSL startup boundary', () => { + it('removes all three eager index values behind one dynamic aggregate capability', () => { + expect(findImport('./cli/cli-installer')).toBeUndefined() + expect(findImport('./cli/linux-bare-orca-dispatcher')).toBeUndefined() + expect(findImport('./cli/wsl-cli-registration-reconciliation')).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('keeps the production value imports in the aggregate capability', () => { + const capabilityPath = join(projectRoot, 'src/main/startup/cli-wsl-startup-capability.ts') + const productionSources = listProductionTypeScriptFiles(join(projectRoot, 'src/main')).map( + (filePath) => ({ filePath, source: readFileSync(filePath, 'utf8') }) + ) + const capabilitySource = productionSources.find( + ({ filePath }) => filePath === capabilityPath + )?.source + + expect(capabilitySource).toContain("import { CliInstaller } from '../cli/cli-installer'") + expect(capabilitySource).toContain( + "import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher'" + ) + expect(capabilitySource).toContain( + "import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation'" + ) + }) + + it('loads after app naming and before the original reconciliation status transition', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const setNameIndex = source.indexOf('app.setName(devInstanceIdentity.appName)', readyIndex) + const importIndex = source.indexOf(capabilityImport, setNameIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const pendingIndex = source.indexOf( + "managedWslCliReconciliationStatus = 'pending'", + factoryIndex + ) + + expect(setNameIndex).toBeGreaterThan(readyIndex) + expect(importIndex).toBeGreaterThan(setNameIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(pendingIndex).toBeGreaterThan(factoryIndex) + }) + + it('preserves reconciliation inputs, status logging, failure handling, and one shared barrier', () => { + const reconciliationIndex = source.indexOf('.reconcileManagedWslCliRegistrations({') + const barrierCreationIndex = source.indexOf( + 'managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier(', + reconciliationIndex + ) + const firstWindowBarrierIndex = source.indexOf( + 'Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])' + ) + const serveBarrierIndex = source.indexOf( + 'await managedWslCliStartupBarrierReady', + barrierCreationIndex + ) + const reconciliationBlock = source.slice(reconciliationIndex, barrierCreationIndex) + + expect(reconciliationBlock).toContain('isPackaged: app.isPackaged,') + expect(reconciliationBlock).toContain('userDataPath: getCanonicalUserDataPath(),') + expect(reconciliationBlock).toContain('appVersion: app.getVersion()') + expect(reconciliationBlock).toContain("result.outcome === 'failed'") + expect(reconciliationBlock).toContain("result.outcome === 'repaired'") + expect(reconciliationBlock).toContain("managedWslCliReconciliationStatus = 'settled'") + expect(reconciliationBlock).toContain("managedWslCliReconciliationStatus = 'failed'") + expect(reconciliationBlock).toContain( + '[wsl-cli] Managed registration reconciliation discovery failed:' + ) + expect(source.slice(barrierCreationIndex, barrierCreationIndex + 180)).toContain( + 'managedWslCliReconciliationReady' + ) + expect(firstWindowBarrierIndex).toBeGreaterThan(-1) + expect(serveBarrierIndex).toBeGreaterThan(firstWindowBarrierIndex) + }) + + it('keeps serve-only platform gates, install behavior, dispatcher inputs, and readiness order', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const barrierIndex = source.indexOf('await managedWslCliStartupBarrierReady', serveIndex) + const rpcIndex = source.indexOf('await runtimeRpc.start()', barrierIndex) + const installGateIndex = source.indexOf( + "if (process.platform === 'darwin' || process.platform === 'linux')", + rpcIndex + ) + const installIndex = source.indexOf( + 'cliWslStartupCapability.installServeCli({', + installGateIndex + ) + const dispatcherGateIndex = source.indexOf( + "if (process.platform === 'linux' && app.isPackaged && process.resourcesPath)", + installIndex + ) + const dispatcherIndex = source.indexOf( + 'cliWslStartupCapability.installLinuxBareOrcaDispatcher({', + dispatcherGateIndex + ) + const readinessIndex = source.indexOf('await printServeReady(serveOptions)', dispatcherIndex) + const installBlock = source.slice(installIndex, dispatcherGateIndex) + const dispatcherBlock = source.slice(dispatcherIndex, readinessIndex) + + expect(barrierIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(barrierIndex) + expect(installGateIndex).toBeGreaterThan(rpcIndex) + expect(installIndex).toBeGreaterThan(installGateIndex) + expect(installBlock).toContain( + 'serve CLI auto-install must not request administrator privileges' + ) + expect(installBlock).toContain('[serve] orca CLI install skipped:') + expect(dispatcherGateIndex).toBeGreaterThan(installIndex) + expect(dispatcherIndex).toBeGreaterThan(dispatcherGateIndex) + expect(dispatcherBlock).toContain('resourcesPath: process.resourcesPath') + expect(dispatcherBlock).toContain('[serve] bare orca dispatcher install skipped:') + expect(readinessIndex).toBeGreaterThan(dispatcherIndex) + }) +}) diff --git a/src/main/startup/cli-wsl-startup-capability.test.ts b/src/main/startup/cli-wsl-startup-capability.test.ts new file mode 100644 index 00000000000..bf4d1af4213 --- /dev/null +++ b/src/main/startup/cli-wsl-startup-capability.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cliMocks = vi.hoisted(() => ({ + constructor: vi.fn(), + install: vi.fn(), + reconcile: vi.fn(), + installDispatcher: vi.fn() +})) + +vi.mock('../cli/cli-installer', () => ({ + CliInstaller: class { + constructor(options: unknown) { + cliMocks.constructor(this, options) + } + + install() { + return cliMocks.install(this) + } + } +})) + +vi.mock('../cli/linux-bare-orca-dispatcher', () => ({ + installLinuxBareOrcaDispatcher: cliMocks.installDispatcher +})) + +vi.mock('../cli/wsl-cli-registration-reconciliation', () => ({ + reconcileManagedWslCliRegistrations: cliMocks.reconcile +})) + +import { createCliWslStartupCapability } from './cli-wsl-startup-capability' + +describe('CLI and WSL startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns one aggregate capability with the original reconciliation identity', () => { + const capability = createCliWslStartupCapability() + const options = { + isPackaged: true, + userDataPath: 'profile-data', + appVersion: '1.2.3' + } + + capability.reconcileManagedWslCliRegistrations(options) + + expect(capability.reconcileManagedWslCliRegistrations).toBe(cliMocks.reconcile) + expect(cliMocks.reconcile).toHaveBeenCalledOnce() + expect(cliMocks.reconcile).toHaveBeenCalledWith(options) + }) + + it('constructs one live installer with the original options and invokes its install', async () => { + const capability = createCliWslStartupCapability() + const options = { privilegedRunner: vi.fn() } + const status = { state: 'installed' } + cliMocks.install.mockResolvedValue(status) + + await expect(capability.installServeCli(options)).resolves.toBe(status) + + expect(cliMocks.constructor).toHaveBeenCalledOnce() + const installer = cliMocks.constructor.mock.calls[0]?.[0] + expect(cliMocks.constructor).toHaveBeenCalledWith(installer, options) + expect(cliMocks.install).toHaveBeenCalledWith(installer) + }) + + it('passes dispatcher options through the original function identity', () => { + const capability = createCliWslStartupCapability() + const options = { resourcesPath: 'resources' } + + capability.installLinuxBareOrcaDispatcher(options) + + expect(capability.installLinuxBareOrcaDispatcher).toBe(cliMocks.installDispatcher) + expect(cliMocks.installDispatcher).toHaveBeenCalledOnce() + expect(cliMocks.installDispatcher).toHaveBeenCalledWith(options) + }) +}) diff --git a/src/main/startup/cli-wsl-startup-capability.ts b/src/main/startup/cli-wsl-startup-capability.ts new file mode 100644 index 00000000000..dfcfb50ca73 --- /dev/null +++ b/src/main/startup/cli-wsl-startup-capability.ts @@ -0,0 +1,13 @@ +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' + +type CliInstallerParameters = ConstructorParameters + +export function createCliWslStartupCapability() { + return { + reconcileManagedWslCliRegistrations, + installServeCli: (...args: CliInstallerParameters) => new CliInstaller(...args).install(), + installLinuxBareOrcaDispatcher + } +} diff --git a/src/main/startup/codex-launch-session-startup-boundary.test.ts b/src/main/startup/codex-launch-session-startup-boundary.test.ts new file mode 100644 index 00000000000..8d04ae3983f --- /dev/null +++ b/src/main/startup/codex-launch-session-startup-boundary.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/codex-launch-session-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('Codex launch/session startup boundary', () => { + it('removes every targeted eager index value import behind one typed seam', () => { + for (const moduleSpecifier of [ + './agent-trust-presets', + './codex/hook-service', + './codex/codex-real-home-hook-install', + './codex/codex-trust-grant-telemetry', + './codex/codex-session-backfill', + './codex/codex-session-index-heal', + './codex/codex-session-migration-scheduler', + './codex/codex-legacy-session-resume', + './codex/codex-session-source-home', + './codex/codex-session-resume-preparation', + './codex/codex-home-paths' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./codex-accounts/runtime-selection')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./startup/codex-launch-session-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after agent hooks and before Store or any live Codex consumer', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const agentHookInstallIndex = source.indexOf( + 'installAgentHookRuntimeStartupCapability(agentHooks)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, agentHookInstallIndex) + const factoryIndex = source.indexOf( + 'const codexLaunchSession = createCodexLaunchSessionStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installCodexLaunchSessionStartupCapability(codexLaunchSession)', + factoryIndex + ) + const storeIndex = source.indexOf('store = createStoreStartupCapability(', installIndex) + const telemetryIndex = source.indexOf( + 'codexLaunchSession.setCodexTrustGrantTelemetry(', + storeIndex + ) + const accountIndex = source.indexOf('createAccountServicesStartupCapability(', telemetryIndex) + const runtimeIndex = source.indexOf('createOrcaRuntimeServiceStartupCapability(', accountIndex) + const terminalIndex = source.indexOf('startTerminalRuntimeStartupServices()', runtimeIndex) + + expect(importIndex).toBeGreaterThan(agentHookInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(storeIndex).toBeGreaterThan(installIndex) + expect(telemetryIndex).toBeGreaterThan(storeIndex) + expect(accountIndex).toBeGreaterThan(telemetryIndex) + expect(runtimeIndex).toBeGreaterThan(accountIndex) + expect(terminalIndex).toBeGreaterThan(runtimeIndex) + }) + + it('keeps predeclared launch, resume, and window consumers fail closed', () => { + const launchStart = source.indexOf('function prepareCodexRuntimeHomeForLaunch(') + const resumeStart = source.indexOf( + 'async function prepareCodexSessionResumeForLaunch(', + launchStart + ) + const windowStart = source.indexOf('function openMainWindow(): BrowserWindow', resumeStart) + const windowEnd = source.indexOf('\nfunction sendOpenFeatureTour', windowStart) + + expect(source.slice(launchStart, resumeStart)).toContain( + 'getCodexLaunchSessionStartupCapability()' + ) + expect(source.slice(resumeStart, windowStart)).toContain( + 'getCodexLaunchSessionStartupCapability()' + ) + expect(source.slice(windowStart, windowEnd)).toContain( + 'getCodexLaunchSessionStartupCapability()' + ) + }) + + it('preserves migration, trust telemetry, and runtime callback identities and order', () => { + const telemetryIndex = source.indexOf('codexLaunchSession.setCodexTrustGrantTelemetry(') + const observabilityIndex = source.indexOf('initObservability()', telemetryIndex) + const migrationIndex = source.indexOf( + 'codexLaunchSession.createCodexSessionMigrationScheduler({', + observabilityIndex + ) + const runtimeIndex = source.indexOf( + 'createOrcaRuntimeServiceStartupCapability(', + migrationIndex + ) + const runtimeBlock = source.slice( + runtimeIndex, + source.indexOf('\n runtime = runtimeService', runtimeIndex) + ) + + expect(observabilityIndex).toBeGreaterThan(telemetryIndex) + expect(migrationIndex).toBeGreaterThan(observabilityIndex) + expect(source.slice(migrationIndex, runtimeIndex)).toContain( + 'startBackfill: codexLaunchSession.startCodexSessionBackfillInBackground' + ) + expect(source.slice(migrationIndex, runtimeIndex)).toContain( + 'startIndexHeal: codexLaunchSession.startCodexSessionIndexHealInBackground' + ) + expect(runtimeBlock).toContain( + 'codexLaunchSession.prepareLegacySharedCodexSessionResume(args, {' + ) + expect(runtimeBlock).toContain('codexLaunchSession.resolveHostCodexSessionSourceHome(') + }) +}) diff --git a/src/main/startup/codex-launch-session-startup-capability.test.ts b/src/main/startup/codex-launch-session-startup-capability.test.ts new file mode 100644 index 00000000000..270086a8acc --- /dev/null +++ b/src/main/startup/codex-launch-session-startup-capability.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' + +const codexMocks = vi.hoisted(() => ({ + codexHookService: { install: vi.fn(), refreshRuntimeUserHooks: vi.fn() }, + createCodexSessionMigrationScheduler: vi.fn(), + ensureRealHomeCodexHookState: vi.fn(), + getOrcaManagedCodexHomePath: vi.fn(), + getSystemCodexHomePath: vi.fn(), + isRealHomeCodexHookLaneUsable: vi.fn(), + markCodexProjectTrusted: vi.fn(), + normalizeCodexRuntimeSelection: vi.fn(), + prepareCodexSessionResume: vi.fn(), + prepareLegacySharedCodexSessionResume: vi.fn(), + resolveHostCodexSessionSourceHome: vi.fn(), + setCodexTrustGrantTelemetry: vi.fn(), + setSystemCodexHomeHookSweepSuppressed: vi.fn(), + startCodexSessionBackfillInBackground: vi.fn(), + startCodexSessionIndexHealInBackground: vi.fn() +})) + +vi.mock('../agent-trust-presets', () => ({ + markCodexProjectTrusted: codexMocks.markCodexProjectTrusted +})) +vi.mock('../codex-accounts/runtime-selection', () => ({ + normalizeCodexRuntimeSelection: codexMocks.normalizeCodexRuntimeSelection +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: codexMocks.codexHookService, + setSystemCodexHomeHookSweepSuppressed: codexMocks.setSystemCodexHomeHookSweepSuppressed +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: codexMocks.ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable: codexMocks.isRealHomeCodexHookLaneUsable +})) +vi.mock('../codex/codex-home-paths', () => ({ + getOrcaManagedCodexHomePath: codexMocks.getOrcaManagedCodexHomePath, + getSystemCodexHomePath: codexMocks.getSystemCodexHomePath +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: codexMocks.prepareLegacySharedCodexSessionResume +})) +vi.mock('../codex/codex-session-backfill', () => ({ + startCodexSessionBackfillInBackground: codexMocks.startCodexSessionBackfillInBackground +})) +vi.mock('../codex/codex-session-index-heal', () => ({ + startCodexSessionIndexHealInBackground: codexMocks.startCodexSessionIndexHealInBackground +})) +vi.mock('../codex/codex-session-migration-scheduler', () => ({ + createCodexSessionMigrationScheduler: codexMocks.createCodexSessionMigrationScheduler +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: codexMocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-session-source-home', () => ({ + resolveHostCodexSessionSourceHome: codexMocks.resolveHostCodexSessionSourceHome +})) +vi.mock('../codex/codex-trust-grant-telemetry', () => ({ + setCodexTrustGrantTelemetry: codexMocks.setCodexTrustGrantTelemetry +})) + +import { createCodexLaunchSessionStartupCapability } from './codex-launch-session-startup-capability' + +describe('Codex launch/session startup capability', () => { + it('returns every original singleton and function identity', () => { + expect(createCodexLaunchSessionStartupCapability()).toEqual(codexMocks) + }) +}) diff --git a/src/main/startup/codex-launch-session-startup-capability.ts b/src/main/startup/codex-launch-session-startup-capability.ts new file mode 100644 index 00000000000..1cadbf5bd54 --- /dev/null +++ b/src/main/startup/codex-launch-session-startup-capability.ts @@ -0,0 +1,53 @@ +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' +import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { + ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable +} from '../codex/codex-real-home-hook-install' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal' +import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' + +export type CodexLaunchSessionStartupCapability = { + codexHookService: typeof codexHookService + createCodexSessionMigrationScheduler: typeof createCodexSessionMigrationScheduler + ensureRealHomeCodexHookState: typeof ensureRealHomeCodexHookState + getOrcaManagedCodexHomePath: typeof getOrcaManagedCodexHomePath + getSystemCodexHomePath: typeof getSystemCodexHomePath + isRealHomeCodexHookLaneUsable: typeof isRealHomeCodexHookLaneUsable + markCodexProjectTrusted: typeof markCodexProjectTrusted + normalizeCodexRuntimeSelection: typeof normalizeCodexRuntimeSelection + prepareCodexSessionResume: typeof prepareCodexSessionResume + prepareLegacySharedCodexSessionResume: typeof prepareLegacySharedCodexSessionResume + resolveHostCodexSessionSourceHome: typeof resolveHostCodexSessionSourceHome + setCodexTrustGrantTelemetry: typeof setCodexTrustGrantTelemetry + setSystemCodexHomeHookSweepSuppressed: typeof setSystemCodexHomeHookSweepSuppressed + startCodexSessionBackfillInBackground: typeof startCodexSessionBackfillInBackground + startCodexSessionIndexHealInBackground: typeof startCodexSessionIndexHealInBackground +} + +export function createCodexLaunchSessionStartupCapability(): CodexLaunchSessionStartupCapability { + return { + codexHookService, + createCodexSessionMigrationScheduler, + ensureRealHomeCodexHookState, + getOrcaManagedCodexHomePath, + getSystemCodexHomePath, + isRealHomeCodexHookLaneUsable, + markCodexProjectTrusted, + normalizeCodexRuntimeSelection, + prepareCodexSessionResume, + prepareLegacySharedCodexSessionResume, + resolveHostCodexSessionSourceHome, + setCodexTrustGrantTelemetry, + setSystemCodexHomeHookSweepSuppressed, + startCodexSessionBackfillInBackground, + startCodexSessionIndexHealInBackground + } +} diff --git a/src/main/startup/codex-launch-session-startup-owner.test.ts b/src/main/startup/codex-launch-session-startup-owner.test.ts new file mode 100644 index 00000000000..ef9d2760e69 --- /dev/null +++ b/src/main/startup/codex-launch-session-startup-owner.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexLaunchSessionStartupCapability } from './codex-launch-session-startup-capability' +import { + getCodexLaunchSessionStartupCapability, + installCodexLaunchSessionStartupCapability +} from './codex-launch-session-startup-owner' + +describe('Codex launch/session startup owner', () => { + it('fails closed before installation and returns the installed identity', () => { + expect(() => getCodexLaunchSessionStartupCapability()).toThrow( + 'Codex launch/session capability must be initialized before use' + ) + const capability = { + codexHookService: {}, + markCodexProjectTrusted: vi.fn() + } as unknown as CodexLaunchSessionStartupCapability + + installCodexLaunchSessionStartupCapability(capability) + + expect(getCodexLaunchSessionStartupCapability()).toBe(capability) + }) +}) diff --git a/src/main/startup/codex-launch-session-startup-owner.ts b/src/main/startup/codex-launch-session-startup-owner.ts new file mode 100644 index 00000000000..8bd6b8b172e --- /dev/null +++ b/src/main/startup/codex-launch-session-startup-owner.ts @@ -0,0 +1,16 @@ +import type { CodexLaunchSessionStartupCapability } from './codex-launch-session-startup-capability' + +let capability: CodexLaunchSessionStartupCapability | null = null + +export function installCodexLaunchSessionStartupCapability( + nextCapability: CodexLaunchSessionStartupCapability +): void { + capability = nextCapability +} + +export function getCodexLaunchSessionStartupCapability(): CodexLaunchSessionStartupCapability { + if (!capability) { + throw new Error('Codex launch/session capability must be initialized before use') + } + return capability +} diff --git a/src/main/startup/codex-usage-store-startup-boundary.test.ts b/src/main/startup/codex-usage-store-startup-boundary.test.ts new file mode 100644 index 00000000000..6ff9b8cceeb --- /dev/null +++ b/src/main/startup/codex-usage-store-startup-boundary.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/codex-usage-store-startup-capability')" +const capabilityFactory = 'codexUsage = await createCodexUsageStoreStartupCapability(store)' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('Codex usage store startup boundary', () => { + it('keeps only the path capture in the eager main graph', () => { + expect(findImport('./codex-usage/store')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./codex-usage/codex-usage-file-path')?.importClause?.isTypeOnly).toBe(false) + expect(source).not.toContain('new CodexUsageStore(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('captures the path before app naming and constructs at the former services point', () => { + const dataPathIndex = source.indexOf('initDataPath()') + const usagePathIndex = source.indexOf('initCodexUsagePath()', dataPathIndex) + const whenReadyIndex = source.indexOf('void app.whenReady()') + const appNameIndex = source.indexOf('app.setName(', whenReadyIndex) + const claudeUsageIndex = source.indexOf( + 'claudeUsage = await createClaudeUsageStoreStartupCapability(store)' + ) + const importIndex = source.indexOf(capabilityImport, claudeUsageIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const openCodeUsageIndex = source.indexOf( + "await import('./startup/opencode-usage-store-startup-capability')", + factoryIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(dataPathIndex).toBeGreaterThanOrEqual(0) + expect(usagePathIndex).toBeGreaterThan(dataPathIndex) + expect(whenReadyIndex).toBeGreaterThan(usagePathIndex) + expect(appNameIndex).toBeGreaterThan(whenReadyIndex) + expect(importIndex).toBeGreaterThan(claudeUsageIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(openCodeUsageIndex).toBeGreaterThan(factoryIndex) + expect(initializedIndex).toBeGreaterThan(openCodeUsageIndex) + }) + + it('preserves readiness, core-handler identity, and automation injection order', () => { + expect(source).toContain( + "throw new Error('Codex usage store must be initialized before opening the main window')" + ) + const handlersIndex = source.indexOf('registerCoreHandlers(') + const factoryIndex = source.indexOf(capabilityFactory) + const automationImportIndex = source.indexOf( + "await import('./startup/automation-service-startup-capability')", + factoryIndex + ) + const automationFactoryIndex = source.indexOf( + 'automations = await createAutomationServiceStartupCapability(store, {', + automationImportIndex + ) + const automationClaudeUsageIndex = source.indexOf('claudeUsage,', automationFactoryIndex) + const automationCodexUsageIndex = source.indexOf('codexUsage,', automationClaudeUsageIndex) + + expect(handlersIndex).toBeGreaterThanOrEqual(0) + expect(source.indexOf('codexUsage,', handlersIndex)).toBeGreaterThan(handlersIndex) + expect(automationImportIndex).toBeGreaterThan(factoryIndex) + expect(automationFactoryIndex).toBeGreaterThan(automationImportIndex) + expect(automationClaudeUsageIndex).toBeGreaterThan(automationFactoryIndex) + expect(automationCodexUsageIndex).toBeGreaterThan(automationClaudeUsageIndex) + expect(source.indexOf("logStartupMilestone('services-initialized')")).toBeGreaterThan( + automationCodexUsageIndex + ) + }) +}) diff --git a/src/main/startup/codex-usage-store-startup-capability.test.ts b/src/main/startup/codex-usage-store-startup-capability.test.ts new file mode 100644 index 00000000000..1b69078a134 --- /dev/null +++ b/src/main/startup/codex-usage-store-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const storeMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../codex-usage/store', () => ({ + CodexUsageStore: class { + constructor(store: unknown) { + storeMocks.constructor(store) + } + } +})) + +import { CodexUsageStore } from '../codex-usage/store' +import { createCodexUsageStoreStartupCapability } from './codex-usage-store-startup-capability' + +describe('Codex usage store startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live store with the original Store input', async () => { + const store = { getRepos: vi.fn() } + + const usageStore = await createCodexUsageStoreStartupCapability(store as never) + + expect(storeMocks.constructor).toHaveBeenCalledOnce() + expect(storeMocks.constructor).toHaveBeenCalledWith(store) + expect(usageStore).toBeInstanceOf(CodexUsageStore) + }) +}) diff --git a/src/main/startup/codex-usage-store-startup-capability.ts b/src/main/startup/codex-usage-store-startup-capability.ts new file mode 100644 index 00000000000..c0c10f54db5 --- /dev/null +++ b/src/main/startup/codex-usage-store-startup-capability.ts @@ -0,0 +1,9 @@ +import { CodexUsageStore } from '../codex-usage/store' + +type CodexUsageStoreParameters = ConstructorParameters + +export function createCodexUsageStoreStartupCapability( + store: CodexUsageStoreParameters[0] +): CodexUsageStore { + return new CodexUsageStore(store) +} diff --git a/src/main/startup/core-ipc-registry-startup-boundary.test.ts b/src/main/startup/core-ipc-registry-startup-boundary.test.ts new file mode 100644 index 00000000000..4dd367e5e2a --- /dev/null +++ b/src/main/startup/core-ipc-registry-startup-boundary.test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const registrySource = readFileSync( + join(process.cwd(), 'src/main/ipc/register-core-handlers.ts'), + 'utf8' +) +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/core-ipc-registry-startup-capability' +const capabilityImport = `import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +function functionSource(name: string): string { + const declaration = sourceFile.statements + .filter(ts.isFunctionDeclaration) + .find((candidate) => candidate.name?.text === name) + expect(declaration).toBeDefined() + return source.slice(declaration!.getStart(sourceFile), declaration!.getEnd()) +} + +describe('core IPC registry startup boundary', () => { + it('keeps the aggregate registry out of the eager main graph behind one import', () => { + expect(findImport('./ipc/register-core-handlers')).toBeUndefined() + expect(findImport(capabilityModule)?.importClause?.isTypeOnly).toBe(true) + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split('getCoreIpcRegistryStartupCapability()')).toHaveLength(2) + expect(source).toContain('let coreIpcRegistry: CoreIpcRegistry | null = null') + expect(source).toContain( + 'let coreIpcRegistryLoadPromise: Promise | null = null' + ) + }) + + it('keeps ordinary headless serve import-free and loads before desktop parallel startup', () => { + const serveStart = source.indexOf('if (serveOptions) {') + const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) + const serveReturn = source.indexOf('return', serveReady) + const desktopLoad = source.indexOf('await loadCoreIpcRegistryForDesktop()', serveReturn) + const parallelStart = source.indexOf('const [win, runtimeRpcStartResult]', desktopLoad) + const windowStart = source.indexOf('Promise.resolve(openMainWindow())', parallelStart) + const rpcStart = source.indexOf('runtimeRpc.start().then(', windowStart) + const serveBlock = source.slice(serveStart, serveReturn) + + expect(serveStart).toBeGreaterThanOrEqual(0) + expect(serveReady).toBeGreaterThan(serveStart) + expect(serveReturn).toBeGreaterThan(serveReady) + expect(serveBlock).not.toContain(capabilityModule) + expect(serveBlock).not.toContain('loadCoreIpcRegistryForDesktop') + expect(desktopLoad).toBeGreaterThan(serveReturn) + expect(parallelStart).toBeGreaterThan(desktopLoad) + expect(windowStart).toBeGreaterThan(parallelStart) + expect(rpcStart).toBeGreaterThan(windowStart) + }) + + it('preserves synchronous registration at the original window construction point', () => { + const declaration = sourceFile.statements + .filter(ts.isFunctionDeclaration) + .find((candidate) => candidate.name?.text === 'openMainWindow') + expect(declaration).toBeDefined() + expect( + Boolean( + declaration!.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ) + ).toBe(false) + + const openWindow = functionSource('openMainWindow') + const guardIndex = openWindow.indexOf('const registerCoreHandlers = coreIpcRegistry') + const createIndex = openWindow.indexOf('const window = createMainWindow(') + const didFinishLoadIndex = openWindow.indexOf( + "window.webContents.on('did-finish-load', onFirstWindowLoad)" + ) + const registryIndex = openWindow.indexOf('registerCoreHandlers(') + const automationIndex = openWindow.indexOf( + 'automations.setWebContents(window.webContents)', + registryIndex + ) + const attachedServicesIndex = openWindow.indexOf('attachMainWindowServices(', automationIndex) + const rateLimitIndex = openWindow.indexOf('rateLimits.attach(window)', attachedServicesIndex) + const rendererLoadIndex = openWindow.indexOf('loadMainWindow(window)', rateLimitIndex) + + expect(guardIndex).toBeGreaterThanOrEqual(0) + expect(createIndex).toBeGreaterThan(guardIndex) + expect(didFinishLoadIndex).toBeGreaterThan(createIndex) + expect(registryIndex).toBeGreaterThan(didFinishLoadIndex) + expect(automationIndex).toBeGreaterThan(registryIndex) + expect(attachedServicesIndex).toBeGreaterThan(automationIndex) + expect(rateLimitIndex).toBeGreaterThan(attachedServicesIndex) + expect(rendererLoadIndex).toBeGreaterThan(rateLimitIndex) + }) + + it('reuses the loaded slot for activation and later window recreation', () => { + const guardedAction = functionSource('runDesktopActionWhenCoreIpcReady') + const trayReopen = functionSource('showMainWindowFromTray') + const settingsOpen = functionSource('openSettingsFromSystemMenu') + const openWindow = functionSource('openMainWindow') + + expect(source).toContain( + 'activateWindow: () => runDesktopActionWhenCoreIpcReady(focusExistingWindow)' + ) + expect(guardedAction).toContain('if (coreIpcRegistry) {') + expect(guardedAction).toContain('action()') + expect(guardedAction).toContain('loadCoreIpcRegistryForDesktop()') + expect(trayReopen).toContain('runDesktopActionWhenCoreIpcReady(openMainWindow)') + expect(settingsOpen).toContain('runDesktopActionWhenCoreIpcReady(openSettingsFromSystemMenu)') + expect(openWindow).toContain( + "throw new Error('Core IPC registry must be loaded before opening the main window')" + ) + expect(openWindow.split('registerCoreHandlers(')).toHaveLength(2) + expect(registrySource).toContain('let registered = false') + expect(registrySource).toContain('if (registered) {') + expect(registrySource).toContain('registered = true') + }) + + it('passes every original service and lifecycle object by identity', () => { + const openWindow = functionSource('openMainWindow') + const registerIndex = openWindow.indexOf('registerCoreHandlers(') + const automationIndex = openWindow.indexOf( + 'automations.setWebContents(window.webContents)', + registerIndex + ) + const registration = openWindow.slice(registerIndex, automationIndex) + const identities = [ + 'store,', + 'runtime,', + 'stats,', + 'claudeUsage,', + 'codexUsage,', + 'openCodeUsage,', + 'codexAccounts,', + 'claudeAccounts,', + 'rateLimits,', + 'rendererWebContentsId,', + 'automations,', + 'agentAwakeService ?? undefined,', + 'crashReports ?? undefined,', + 'keybindings,', + 'pluginService ?? undefined,', + 'pluginMarketplaceService && pluginMarketplaceInstaller' + ] + let previousIndex = -1 + + for (const identity of identities) { + const identityIndex = registration.indexOf(identity, previousIndex + 1) + expect(identityIndex).toBeGreaterThan(previousIndex) + previousIndex = identityIndex + } + expect(registration).toContain('prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch') + expect(registration).toContain( + 'prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)' + ) + expect(registration).toContain( + 'await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })' + ) + expect(registration).toContain( + 'onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated()' + ) + expect(registration).toContain( + 'onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()' + ) + }) +}) diff --git a/src/main/startup/core-ipc-registry-startup-capability.test.ts b/src/main/startup/core-ipc-registry-startup-capability.test.ts new file mode 100644 index 00000000000..9ee91b442ce --- /dev/null +++ b/src/main/startup/core-ipc-registry-startup-capability.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +const registerCoreHandlers = vi.hoisted(() => vi.fn()) + +vi.mock('../ipc/register-core-handlers', () => ({ registerCoreHandlers })) + +import { + getCoreIpcRegistryStartupCapability, + type CoreIpcRegistry +} from './core-ipc-registry-startup-capability' + +describe('core IPC registry startup capability', () => { + it('returns the exact aggregate register function without invoking it', () => { + const registry = getCoreIpcRegistryStartupCapability() + + expect(registry).toBe(registerCoreHandlers as CoreIpcRegistry) + expect(registerCoreHandlers).not.toHaveBeenCalled() + }) + + it('reuses the same module-level one-time registry across later windows', () => { + expect(getCoreIpcRegistryStartupCapability()).toBe(getCoreIpcRegistryStartupCapability()) + }) +}) diff --git a/src/main/startup/core-ipc-registry-startup-capability.ts b/src/main/startup/core-ipc-registry-startup-capability.ts new file mode 100644 index 00000000000..290c2fb049b --- /dev/null +++ b/src/main/startup/core-ipc-registry-startup-capability.ts @@ -0,0 +1,7 @@ +import { registerCoreHandlers } from '../ipc/register-core-handlers' + +export type CoreIpcRegistry = typeof registerCoreHandlers + +export function getCoreIpcRegistryStartupCapability(): CoreIpcRegistry { + return registerCoreHandlers +} diff --git a/src/main/startup/crash-hang-runtime-startup-boundary.test.ts b/src/main/startup/crash-hang-runtime-startup-boundary.test.ts new file mode 100644 index 00000000000..b8c092c04ac --- /dev/null +++ b/src/main/startup/crash-hang-runtime-startup-boundary.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/crash-hang-runtime-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('crash/hang runtime startup boundary', () => { + it('keeps only the proven pre-ready breadcrumb and lifecycle leaves eager', () => { + expect(findImport('./crash-reporting/crash-breadcrumb-store')).toBeDefined() + expect(findImport('./crash-reporting/main-process-lifecycle-identity')).toBeDefined() + for (const moduleSpecifier of [ + './crash-reporting/durable-crash-breadcrumb', + './crash-reporting/crash-report-store', + './crash-reporting/process-gone-classification', + './crash-reporting/process-gone-recorder', + './hang-watchdog/hang-detection-marker', + './hang-watchdog/main-thread-hang-watchdog' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./startup/crash-hang-runtime-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)?.importClause?.isTypeOnly).toBe(true) + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after telemetry and before store, watchdog, marker, and certificate consumers', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const telemetryInstallIndex = source.indexOf( + 'installTelemetryObservabilityStartupCapability(telemetryObservability)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, telemetryInstallIndex) + const factoryIndex = source.indexOf( + 'const crashHangRuntime = createCrashHangRuntimeStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installCrashHangRuntimeStartupCapability(crashHangRuntime)', + factoryIndex + ) + const storeIndex = source.indexOf( + 'crashReports = crashHangRuntime.CrashReportStore.fromUserData()', + installIndex + ) + const watchdogIndex = source.indexOf( + 'crashHangRuntime.installMainThreadHangWatchdog({', + storeIndex + ) + const markerIndex = source.indexOf( + 'crashHangRuntime.consumeHangDetectionMarker(', + watchdogIndex + ) + const certificateIndex = source.indexOf("'certificate-error'", markerIndex) + const appNameIndex = source.indexOf( + 'app.setName(devInstanceIdentity.appName)', + certificateIndex + ) + + expect(importIndex).toBeGreaterThan(telemetryInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(storeIndex).toBeGreaterThan(installIndex) + expect(watchdogIndex).toBeGreaterThan(storeIndex) + expect(markerIndex).toBeGreaterThan(watchdogIndex) + expect(certificateIndex).toBeGreaterThan(markerIndex) + expect(appNameIndex).toBeGreaterThan(certificateIndex) + }) + + it('preserves pre-ready breadcrumbs and defers the report store without moving GPU policy', () => { + const bootstrapStart = source.indexOf('if (hasSingleInstanceLock) {') + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const bootstrap = source.slice(bootstrapStart, readyIndex) + + expect(bootstrap).toContain("recordCrashBreadcrumb('app_started', {") + expect(bootstrap).toContain('...getMainProcessLifecycleIdentity()') + expect(bootstrap).toContain('maybeApplyGpuFallbackForThisLaunch()') + expect(bootstrap).not.toContain('CrashReportStore.fromUserData') + expect(source).toContain("recordCrashBreadcrumb('gpu_fallback_applied', {") + expect(findImport('./crash-reporting/gpu-crash-fallback-decision')).toBeDefined() + expect(findImport('./crash-reporting/gpu-fallback-restart-prompt')).toBeDefined() + }) + + it('routes every post-ready live callback through the exact installed owner', () => { + const agentBreadcrumbStart = source.indexOf('function recordAgentStateCrashBreadcrumb(') + const bootstrapStart = source.indexOf('if (hasSingleInstanceLock) {', agentBreadcrumbStart) + const agentBreadcrumbBlock = source.slice(agentBreadcrumbStart, bootstrapStart) + const windowStart = source.indexOf('function openMainWindow(): BrowserWindow') + const windowEnd = source.indexOf('\nfunction sendOpenFeatureTour', windowStart) + const windowBlock = source.slice(windowStart, windowEnd) + const processGoneStart = source.indexOf('function recordProcessGoneCrash(') + const processGoneEnd = source.indexOf('\nfunction shutdownWatchersOnce', processGoneStart) + const processGoneBlock = source.slice(processGoneStart, processGoneEnd) + + expect(agentBreadcrumbBlock).toContain( + 'getCrashHangRuntimeStartupCapability().recordCoalescedCrashBreadcrumb({' + ) + expect(windowBlock).toContain('const crashHangRuntime = getCrashHangRuntimeStartupCapability()') + expect(windowBlock).toContain('crashHangRuntime.shouldRecoverRendererAfterProcessGone({') + expect(windowBlock).toContain('crashHangRuntime.recordDurableCrashBreadcrumb(') + expect(processGoneBlock).toContain( + 'getCrashHangRuntimeStartupCapability().recordProcessGoneCrash(crashReports, {' + ) + }) +}) diff --git a/src/main/startup/crash-hang-runtime-startup-capability.test.ts b/src/main/startup/crash-hang-runtime-startup-capability.test.ts new file mode 100644 index 00000000000..ecb7d071e74 --- /dev/null +++ b/src/main/startup/crash-hang-runtime-startup-capability.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' + +const crashHangMocks = vi.hoisted(() => ({ + consumeHangDetectionMarker: vi.fn(), + CrashReportStore: class CrashReportStore {}, + getMainProcessLifecycleIdentity: vi.fn(), + hangDetectionMarkerPath: vi.fn(), + installMainThreadHangWatchdog: vi.fn(), + recordCoalescedCrashBreadcrumb: vi.fn(), + recordCrashBreadcrumb: vi.fn(), + recordDurableCrashBreadcrumb: vi.fn(), + recordProcessGoneCrash: vi.fn(), + shouldRecoverRendererAfterProcessGone: vi.fn() +})) + +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCoalescedCrashBreadcrumb: crashHangMocks.recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb: crashHangMocks.recordCrashBreadcrumb +})) +vi.mock('../crash-reporting/crash-report-store', () => ({ + CrashReportStore: crashHangMocks.CrashReportStore +})) +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordDurableCrashBreadcrumb: crashHangMocks.recordDurableCrashBreadcrumb +})) +vi.mock('../crash-reporting/main-process-lifecycle-identity', () => ({ + getMainProcessLifecycleIdentity: crashHangMocks.getMainProcessLifecycleIdentity +})) +vi.mock('../crash-reporting/process-gone-classification', () => ({ + shouldRecoverRendererAfterProcessGone: crashHangMocks.shouldRecoverRendererAfterProcessGone +})) +vi.mock('../crash-reporting/process-gone-recorder', () => ({ + recordProcessGoneCrash: crashHangMocks.recordProcessGoneCrash +})) +vi.mock('../hang-watchdog/hang-detection-marker', () => ({ + consumeHangDetectionMarker: crashHangMocks.consumeHangDetectionMarker, + hangDetectionMarkerPath: crashHangMocks.hangDetectionMarkerPath +})) +vi.mock('../hang-watchdog/main-thread-hang-watchdog', () => ({ + installMainThreadHangWatchdog: crashHangMocks.installMainThreadHangWatchdog +})) + +import { createCrashHangRuntimeStartupCapability } from './crash-hang-runtime-startup-capability' + +describe('crash/hang runtime startup capability', () => { + it('returns every original class and function identity', () => { + expect(createCrashHangRuntimeStartupCapability()).toEqual(crashHangMocks) + }) +}) diff --git a/src/main/startup/crash-hang-runtime-startup-capability.ts b/src/main/startup/crash-hang-runtime-startup-capability.ts new file mode 100644 index 00000000000..8b395642689 --- /dev/null +++ b/src/main/startup/crash-hang-runtime-startup-capability.ts @@ -0,0 +1,45 @@ +import { + recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { CrashReportStore } from '../crash-reporting/crash-report-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { recordProcessGoneCrash } from '../crash-reporting/process-gone-recorder' +import { + consumeHangDetectionMarker, + hangDetectionMarkerPath +} from '../hang-watchdog/hang-detection-marker' +import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' + +export type { CrashReportStore } from '../crash-reporting/crash-report-store' +export type { ExpectedTeardownScope } from '../crash-reporting/process-gone-classification' + +export type CrashHangRuntimeStartupCapability = { + consumeHangDetectionMarker: typeof consumeHangDetectionMarker + CrashReportStore: typeof CrashReportStore + getMainProcessLifecycleIdentity: typeof getMainProcessLifecycleIdentity + hangDetectionMarkerPath: typeof hangDetectionMarkerPath + installMainThreadHangWatchdog: typeof installMainThreadHangWatchdog + recordCoalescedCrashBreadcrumb: typeof recordCoalescedCrashBreadcrumb + recordCrashBreadcrumb: typeof recordCrashBreadcrumb + recordDurableCrashBreadcrumb: typeof recordDurableCrashBreadcrumb + recordProcessGoneCrash: typeof recordProcessGoneCrash + shouldRecoverRendererAfterProcessGone: typeof shouldRecoverRendererAfterProcessGone +} + +export function createCrashHangRuntimeStartupCapability(): CrashHangRuntimeStartupCapability { + return { + consumeHangDetectionMarker, + CrashReportStore, + getMainProcessLifecycleIdentity, + hangDetectionMarkerPath, + installMainThreadHangWatchdog, + recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb, + recordDurableCrashBreadcrumb, + recordProcessGoneCrash, + shouldRecoverRendererAfterProcessGone + } +} diff --git a/src/main/startup/crash-hang-runtime-startup-owner.test.ts b/src/main/startup/crash-hang-runtime-startup-owner.test.ts new file mode 100644 index 00000000000..d84d5b226d1 --- /dev/null +++ b/src/main/startup/crash-hang-runtime-startup-owner.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('crash/hang runtime startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation', async () => { + const { + getCrashHangRuntimeStartupCapability, + getCrashHangRuntimeStartupCapabilityIfInstalled + } = await import('./crash-hang-runtime-startup-owner') + + expect(getCrashHangRuntimeStartupCapabilityIfInstalled()).toBeNull() + expect(() => getCrashHangRuntimeStartupCapability()).toThrow( + 'Crash/hang runtime capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { + getCrashHangRuntimeStartupCapability, + getCrashHangRuntimeStartupCapabilityIfInstalled, + installCrashHangRuntimeStartupCapability + } = await import('./crash-hang-runtime-startup-owner') + const capability = { + consumeHangDetectionMarker: vi.fn(), + CrashReportStore: class CrashReportStore {}, + getMainProcessLifecycleIdentity: vi.fn(), + hangDetectionMarkerPath: vi.fn(), + installMainThreadHangWatchdog: vi.fn(), + recordCoalescedCrashBreadcrumb: vi.fn(), + recordCrashBreadcrumb: vi.fn(), + recordDurableCrashBreadcrumb: vi.fn(), + recordProcessGoneCrash: vi.fn(), + shouldRecoverRendererAfterProcessGone: vi.fn() + } + + installCrashHangRuntimeStartupCapability(capability as never) + + expect(getCrashHangRuntimeStartupCapability()).toBe(capability) + expect(getCrashHangRuntimeStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/crash-hang-runtime-startup-owner.ts b/src/main/startup/crash-hang-runtime-startup-owner.ts new file mode 100644 index 00000000000..31063c56d94 --- /dev/null +++ b/src/main/startup/crash-hang-runtime-startup-owner.ts @@ -0,0 +1,20 @@ +import type { CrashHangRuntimeStartupCapability } from './crash-hang-runtime-startup-capability' + +let capability: CrashHangRuntimeStartupCapability | null = null + +export function installCrashHangRuntimeStartupCapability( + nextCapability: CrashHangRuntimeStartupCapability +): void { + capability = nextCapability +} + +export function getCrashHangRuntimeStartupCapability(): CrashHangRuntimeStartupCapability { + if (!capability) { + throw new Error('Crash/hang runtime capability must be initialized before use') + } + return capability +} + +export function getCrashHangRuntimeStartupCapabilityIfInstalled(): CrashHangRuntimeStartupCapability | null { + return capability +} diff --git a/src/main/startup/desktop-relay-service-startup-boundary.test.ts b/src/main/startup/desktop-relay-service-startup-boundary.test.ts new file mode 100644 index 00000000000..159c5f6f464 --- /dev/null +++ b/src/main/startup/desktop-relay-service-startup-boundary.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/desktop-relay-service-startup-capability')" +const capabilityFactory = 'const relayService = await createDesktopRelayServiceStartupCapability({' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('Desktop relay service startup boundary', () => { + it('keeps the service type-only and the relay status contract eagerly type-only', () => { + expect(findImport('./runtime/relay/desktop-relay-service')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./runtime/relay/relay-session-broker')?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new DesktopRelayService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('loads only inside configured cloud auth after desktop window and RPC startup', () => { + const desktopStartupIndex = source.indexOf('const [win, runtimeRpcStartResult] = await Promise') + const rpcFailureIndex = source.indexOf('if (!runtimeRpcStartResult.ok)', desktopStartupIndex) + const cloudAuthIndex = source.indexOf( + 'const cloudAuth = getOrcaCloudAuthConfig()', + rpcFailureIndex + ) + const configuredIndex = source.indexOf('if (cloudAuth.configured) {', cloudAuthIndex) + const tryIndex = source.indexOf('try {', configuredIndex) + const importIndex = source.indexOf(capabilityImport, tryIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const catchIndex = source.indexOf('} catch (error) {', factoryIndex) + + expect(desktopStartupIndex).toBeGreaterThanOrEqual(0) + expect(rpcFailureIndex).toBeGreaterThan(desktopStartupIndex) + expect(cloudAuthIndex).toBeGreaterThan(rpcFailureIndex) + expect(configuredIndex).toBeGreaterThan(cloudAuthIndex) + expect(tryIndex).toBeGreaterThan(configuredIndex) + expect(importIndex).toBeGreaterThan(tryIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(catchIndex).toBeGreaterThan(factoryIndex) + }) + + it('assigns the live service before provider wiring, status publication, and start', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const assignmentIndex = source.indexOf('desktopRelayService = relayService', factoryIndex) + const providerIndex = source.indexOf( + 'runtimeRpc.setMobileRelayPairingProvider({', + assignmentIndex + ) + const statusAssignmentIndex = source.indexOf('desktopRelayStatus = status', factoryIndex) + const statusNotificationIndex = source.indexOf( + "mainWindow?.webContents.send('mobile:relayStatusChanged', status)", + statusAssignmentIndex + ) + const startIndex = source.indexOf('relayService.start()', providerIndex) + + expect(source).toContain("let desktopRelayStatus: RelayBrokerStatus = 'offline'") + expect(statusAssignmentIndex).toBeGreaterThan(factoryIndex) + expect(statusNotificationIndex).toBeGreaterThan(statusAssignmentIndex) + expect(assignmentIndex).toBeGreaterThan(factoryIndex) + expect(providerIndex).toBeGreaterThan(assignmentIndex) + expect(source.indexOf('relayService.createPairingRelay(', providerIndex)).toBeGreaterThan( + providerIndex + ) + expect(startIndex).toBeGreaterThan(providerIndex) + }) + + it('keeps auth mutation, sign-out, relaunch, and quit callbacks on the live service', () => { + expect(source).toContain('desktopRelayService?.fenceAndCloseNow()') + expect(source).toContain('onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated()') + expect(source).toContain( + 'onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()' + ) + expect(source).toContain("app.on('before-quit', () => {") + expect(source).toContain('runtimeRpc?.setMobileRelayPairingProvider(null)') + expect(source).toContain("'[relay] Desktop relay startup unavailable:'") + }) +}) diff --git a/src/main/startup/desktop-relay-service-startup-capability.test.ts b/src/main/startup/desktop-relay-service-startup-capability.test.ts new file mode 100644 index 00000000000..2d1768bc1ef --- /dev/null +++ b/src/main/startup/desktop-relay-service-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../runtime/relay/desktop-relay-service', () => ({ + DesktopRelayService: class { + constructor(options: unknown) { + serviceMocks.constructor(options) + } + } +})) + +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { createDesktopRelayServiceStartupCapability } from './desktop-relay-service-startup-capability' + +describe('Desktop relay service startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the live service constructed with the original options', async () => { + const options = { authConfig: { clientId: 'client' } } + + const service = await createDesktopRelayServiceStartupCapability(options as never) + + expect(serviceMocks.constructor).toHaveBeenCalledOnce() + expect(serviceMocks.constructor).toHaveBeenCalledWith(options) + expect(service).toBeInstanceOf(DesktopRelayService) + }) +}) diff --git a/src/main/startup/desktop-relay-service-startup-capability.ts b/src/main/startup/desktop-relay-service-startup-capability.ts new file mode 100644 index 00000000000..eb2cfc57ca0 --- /dev/null +++ b/src/main/startup/desktop-relay-service-startup-capability.ts @@ -0,0 +1,9 @@ +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' + +type DesktopRelayServiceParameters = ConstructorParameters + +export function createDesktopRelayServiceStartupCapability( + options: DesktopRelayServiceParameters[0] +): DesktopRelayService { + return new DesktopRelayService(options) +} diff --git a/src/main/startup/desktop-shell-startup-boundary.test.ts b/src/main/startup/desktop-shell-startup-boundary.test.ts new file mode 100644 index 00000000000..4b017e9d323 --- /dev/null +++ b/src/main/startup/desktop-shell-startup-boundary.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/desktop-shell-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('desktop-shell startup boundary', () => { + it('removes the eager desktop-shell graph while retaining the pre-ready focus leaf', () => { + for (const moduleSpecifier of [ + './menu/register-app-menu', + './tray/system-tray', + './window/dashboard-popout-window', + './window/macos-app-activation', + './window/main-window-visibility', + './dock/unread-badge', + './ipc/notifications' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./window/focus-existing-window')).toBeDefined() + expect(findImport('./startup/desktop-shell-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)?.importClause?.isTypeOnly).toBe(true) + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after retained app-ready capabilities and before desktop services', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const updaterIndex = source.indexOf( + 'installUpdaterRuntimeStartupCapability(updaterRuntime)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, updaterIndex) + const factoryIndex = source.indexOf( + 'const desktopShell = createDesktopShellStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installDesktopShellStartupCapability(desktopShell)', + factoryIndex + ) + const activationFactoryIndex = source.indexOf( + 'desktopShell.createMacAppActivationHandler({', + installIndex + ) + const browserManagerIndex = source.indexOf( + 'const browserManager = browserKernel.browserManager', + activationFactoryIndex + ) + const certificateIndex = source.indexOf("'certificate-error'", browserManagerIndex) + + expect(importIndex).toBeGreaterThan(updaterIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(activationFactoryIndex).toBeGreaterThan(installIndex) + expect(browserManagerIndex).toBeGreaterThan(activationFactoryIndex) + expect(certificateIndex).toBeGreaterThan(browserManagerIndex) + }) + + it('preserves synchronous second-instance activation through the eager focus leaf', () => { + const lockIndex = source.indexOf('acquireSingleInstanceLock(app, requestDesktopActivation)') + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const focusStart = source.indexOf('function focusExistingWindow(): void {') + const focusEnd = source.indexOf('\n}', focusStart) + const focusBlock = source.slice(focusStart, focusEnd) + const gateStart = source.indexOf( + 'const desktopActivationGate = createServeDesktopActivationGate' + ) + const gateEnd = source.indexOf('\n})', gateStart) + const gateBlock = source.slice(gateStart, gateEnd) + + expect(lockIndex).toBeGreaterThanOrEqual(0) + expect(lockIndex).toBeLessThan(readyIndex) + expect(focusBlock).toContain('focusExistingMainWindow({') + expect(focusBlock).toContain('openWindow: openMainWindow') + expect(gateBlock).toContain( + 'activateWindow: () => runDesktopActionWhenCoreIpcReady(focusExistingWindow)' + ) + }) + + it('retains menu, tray, activation, recreation, and serve-promotion consumers', () => { + const i18nIndex = source.indexOf("logStartupMilestone('i18n-ready')") + const menuIndex = source.indexOf('desktopShell.registerAppMenu({', i18nIndex) + const terminalIndex = source.indexOf('startTerminalRuntimeStartupServices()', menuIndex) + const activationIndex = source.indexOf( + "app.on('activate', handleMacAppActivation)", + terminalIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', activationIndex) + const windowOpenIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) + + expect(menuIndex).toBeGreaterThan(i18nIndex) + expect(activationIndex).toBeGreaterThan(terminalIndex) + expect(serveIndex).toBeGreaterThan(activationIndex) + expect(windowOpenIndex).toBeGreaterThan(serveIndex) + expect(source).toContain("window.on('show', notifyMainWindowBecameVisible)") + expect(source).toContain("window.on('restore', notifyMainWindowBecameVisible)") + expect(source).toContain("window.on('show', () => setTrayAttention(false))") + expect(source).toContain('desktopShell.zoomDashboardPopoutIfFocused') + expect(source).toContain('syncMacMenuBarIcon(settings.showMenuBarIcon !== false)') + }) + + it('keeps pre-install shutdown cleanup fail-closed and post-ready cleanup exact', () => { + const willQuitIndex = source.indexOf("app.on('will-quit', (e) => {") + const willQuitBlock = source.slice(willQuitIndex) + + expect(willQuitBlock).toContain( + 'getDesktopShellStartupCapabilityIfInstalled()?.destroySystemTray()' + ) + expect(willQuitBlock).toContain( + 'getDesktopShellStartupCapabilityIfInstalled()?.setUnreadDockBadgeCount(0)' + ) + expect(source).toContain('getDesktopShellStartupCapability().destroySystemTray()') + expect(source).toContain('desktopShell.triggerStartupNotificationRegistration(store)') + }) +}) diff --git a/src/main/startup/desktop-shell-startup-capability.test.ts b/src/main/startup/desktop-shell-startup-capability.test.ts new file mode 100644 index 00000000000..4aeb3aede5f --- /dev/null +++ b/src/main/startup/desktop-shell-startup-capability.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' + +const desktopShellMocks = vi.hoisted(() => ({ + createMacAppActivationHandler: vi.fn(), + createSystemTray: vi.fn(), + destroySystemTray: vi.fn(), + getNextDefaultOnAppearanceSettingValue: vi.fn(), + notifyMainWindowBecameVisible: vi.fn(), + rebuildAppMenu: vi.fn(), + registerAppMenu: vi.fn(), + setMacMenuBarIconVisible: vi.fn(), + setTrayAttention: vi.fn(), + setUnreadDockBadgeCount: vi.fn(), + triggerStartupNotificationRegistration: vi.fn(), + zoomDashboardPopoutIfFocused: vi.fn() +})) + +vi.mock('../dock/unread-badge', () => ({ + setUnreadDockBadgeCount: desktopShellMocks.setUnreadDockBadgeCount +})) +vi.mock('../menu/register-app-menu', () => ({ + getNextDefaultOnAppearanceSettingValue: desktopShellMocks.getNextDefaultOnAppearanceSettingValue, + rebuildAppMenu: desktopShellMocks.rebuildAppMenu, + registerAppMenu: desktopShellMocks.registerAppMenu +})) +vi.mock('../ipc/notifications', () => ({ + triggerStartupNotificationRegistration: desktopShellMocks.triggerStartupNotificationRegistration +})) +vi.mock('../tray/system-tray', () => ({ + createSystemTray: desktopShellMocks.createSystemTray, + destroySystemTray: desktopShellMocks.destroySystemTray, + setMacMenuBarIconVisible: desktopShellMocks.setMacMenuBarIconVisible, + setTrayAttention: desktopShellMocks.setTrayAttention +})) +vi.mock('../window/dashboard-popout-window', () => ({ + zoomDashboardPopoutIfFocused: desktopShellMocks.zoomDashboardPopoutIfFocused +})) +vi.mock('../window/macos-app-activation', () => ({ + createMacAppActivationHandler: desktopShellMocks.createMacAppActivationHandler +})) +vi.mock('../window/main-window-visibility', () => ({ + notifyMainWindowBecameVisible: desktopShellMocks.notifyMainWindowBecameVisible +})) + +import { createDesktopShellStartupCapability } from './desktop-shell-startup-capability' + +describe('desktop-shell startup capability', () => { + it('returns every original desktop-shell identity', () => { + expect(createDesktopShellStartupCapability()).toEqual(desktopShellMocks) + }) +}) diff --git a/src/main/startup/desktop-shell-startup-capability.ts b/src/main/startup/desktop-shell-startup-capability.ts new file mode 100644 index 00000000000..6d1ae2b7b0a --- /dev/null +++ b/src/main/startup/desktop-shell-startup-capability.ts @@ -0,0 +1,50 @@ +import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { + getNextDefaultOnAppearanceSettingValue, + rebuildAppMenu, + registerAppMenu +} from '../menu/register-app-menu' +import { triggerStartupNotificationRegistration } from '../ipc/notifications' +import { + createSystemTray, + destroySystemTray, + setMacMenuBarIconVisible, + setTrayAttention +} from '../tray/system-tray' +import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window' +import { createMacAppActivationHandler } from '../window/macos-app-activation' +import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' + +export type { SystemTrayOptions } from '../tray/system-tray' + +export type DesktopShellStartupCapability = { + createMacAppActivationHandler: typeof createMacAppActivationHandler + createSystemTray: typeof createSystemTray + destroySystemTray: typeof destroySystemTray + getNextDefaultOnAppearanceSettingValue: typeof getNextDefaultOnAppearanceSettingValue + notifyMainWindowBecameVisible: typeof notifyMainWindowBecameVisible + rebuildAppMenu: typeof rebuildAppMenu + registerAppMenu: typeof registerAppMenu + setMacMenuBarIconVisible: typeof setMacMenuBarIconVisible + setTrayAttention: typeof setTrayAttention + setUnreadDockBadgeCount: typeof setUnreadDockBadgeCount + triggerStartupNotificationRegistration: typeof triggerStartupNotificationRegistration + zoomDashboardPopoutIfFocused: typeof zoomDashboardPopoutIfFocused +} + +export function createDesktopShellStartupCapability(): DesktopShellStartupCapability { + return { + createMacAppActivationHandler, + createSystemTray, + destroySystemTray, + getNextDefaultOnAppearanceSettingValue, + notifyMainWindowBecameVisible, + rebuildAppMenu, + registerAppMenu, + setMacMenuBarIconVisible, + setTrayAttention, + setUnreadDockBadgeCount, + triggerStartupNotificationRegistration, + zoomDashboardPopoutIfFocused + } +} diff --git a/src/main/startup/desktop-shell-startup-owner.test.ts b/src/main/startup/desktop-shell-startup-owner.test.ts new file mode 100644 index 00000000000..85c66170648 --- /dev/null +++ b/src/main/startup/desktop-shell-startup-owner.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('desktop-shell startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation while optional teardown reads null', async () => { + const { getDesktopShellStartupCapability, getDesktopShellStartupCapabilityIfInstalled } = + await import('./desktop-shell-startup-owner') + + expect(getDesktopShellStartupCapabilityIfInstalled()).toBeNull() + expect(() => getDesktopShellStartupCapability()).toThrow( + 'Desktop-shell capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { + getDesktopShellStartupCapability, + getDesktopShellStartupCapabilityIfInstalled, + installDesktopShellStartupCapability + } = await import('./desktop-shell-startup-owner') + const capability = { + createMacAppActivationHandler: vi.fn(), + createSystemTray: vi.fn(), + destroySystemTray: vi.fn(), + getNextDefaultOnAppearanceSettingValue: vi.fn(), + notifyMainWindowBecameVisible: vi.fn(), + rebuildAppMenu: vi.fn(), + registerAppMenu: vi.fn(), + setMacMenuBarIconVisible: vi.fn(), + setTrayAttention: vi.fn(), + setUnreadDockBadgeCount: vi.fn(), + triggerStartupNotificationRegistration: vi.fn(), + zoomDashboardPopoutIfFocused: vi.fn() + } + + installDesktopShellStartupCapability(capability as never) + + expect(getDesktopShellStartupCapability()).toBe(capability) + expect(getDesktopShellStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/desktop-shell-startup-owner.ts b/src/main/startup/desktop-shell-startup-owner.ts new file mode 100644 index 00000000000..6fff259657e --- /dev/null +++ b/src/main/startup/desktop-shell-startup-owner.ts @@ -0,0 +1,20 @@ +import type { DesktopShellStartupCapability } from './desktop-shell-startup-capability' + +let capability: DesktopShellStartupCapability | null = null + +export function installDesktopShellStartupCapability( + nextCapability: DesktopShellStartupCapability +): void { + capability = nextCapability +} + +export function getDesktopShellStartupCapability(): DesktopShellStartupCapability { + if (!capability) { + throw new Error('Desktop-shell capability must be initialized before use') + } + return capability +} + +export function getDesktopShellStartupCapabilityIfInstalled(): DesktopShellStartupCapability | null { + return capability +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 6c5564e3e19..3e90c9e4f82 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -50,7 +50,7 @@ describe('startup ordering', () => { const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) const barrier = source.slice(barrierStart, barrierEnd) const reconciliationStart = source.indexOf( - 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + 'managedWslCliReconciliationReady = cliWslStartupCapability' ) const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) diff --git a/src/main/startup/emulator-startup-boundary.test.ts b/src/main/startup/emulator-startup-boundary.test.ts new file mode 100644 index 00000000000..9334e55c77f --- /dev/null +++ b/src/main/startup/emulator-startup-boundary.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/emulator-startup-capability')" + +describe('emulator startup boundary', () => { + it('keeps the bridge implementation out of the eager main graph', () => { + const staticImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .map((declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text) + + expect(staticImports).not.toContain('./emulator/emulator-bridge') + expect(source).not.toContain('new EmulatorBridge(') + expect(source).toContain(capabilityImport) + }) + + it('attaches the bridge before services are observable', () => { + const importIndex = source.indexOf(capabilityImport) + const attachIndex = source.indexOf( + 'attachEmulatorStartupCapability(runtimeService)', + importIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(attachIndex).toBeGreaterThan(importIndex) + expect(initializedIndex).toBeGreaterThan(attachIndex) + }) +}) diff --git a/src/main/startup/emulator-startup-capability.test.ts b/src/main/startup/emulator-startup-capability.test.ts new file mode 100644 index 00000000000..2c9d10ab494 --- /dev/null +++ b/src/main/startup/emulator-startup-capability.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { attachEmulatorStartupCapability } from './emulator-startup-capability' + +describe('emulator startup capability', () => { + it('attaches one bridge with both desktop emulator backends', () => { + const setEmulatorBridge = vi.fn() + + const bridge = attachEmulatorStartupCapability({ setEmulatorBridge }) + + expect(setEmulatorBridge).toHaveBeenCalledOnce() + expect(setEmulatorBridge).toHaveBeenCalledWith(bridge) + expect(bridge.listBackends().map((backend) => backend.kind)).toEqual(['ios', 'android']) + }) +}) diff --git a/src/main/startup/emulator-startup-capability.ts b/src/main/startup/emulator-startup-capability.ts new file mode 100644 index 00000000000..0e0a69bbd0c --- /dev/null +++ b/src/main/startup/emulator-startup-capability.ts @@ -0,0 +1,10 @@ +import { EmulatorBridge } from '../emulator/emulator-bridge' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type EmulatorStartupRuntime = Pick + +export function attachEmulatorStartupCapability(runtime: EmulatorStartupRuntime): EmulatorBridge { + const bridge = new EmulatorBridge() + runtime.setEmulatorBridge(bridge) + return bridge +} diff --git a/src/main/startup/keybinding-service-startup-boundary.test.ts b/src/main/startup/keybinding-service-startup-boundary.test.ts new file mode 100644 index 00000000000..e498ff928cb --- /dev/null +++ b/src/main/startup/keybinding-service-startup-boundary.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/keybinding-service-startup-capability')" +const capabilityFactory = 'keybindings = await createKeybindingServiceStartupCapability({' + +describe('keybinding service startup boundary', () => { + it('keeps the service implementation out of the eager main graph', () => { + const serviceImport = sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => + (declaration.moduleSpecifier as ts.StringLiteral).text === + './keybindings/keybinding-service' + ) + + expect(serviceImport?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new KeybindingService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('completes construction before settings resolution and observable readiness', () => { + const importIndex = source.indexOf(capabilityImport) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const factoryEndIndex = source.indexOf('browserManager.setSettingsResolver', factoryIndex) + const pluginIndex = source.indexOf('pluginService = new PluginService', factoryEndIndex) + const initializedIndex = source.indexOf( + "logStartupMilestone('services-initialized')", + pluginIndex + ) + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(factoryEndIndex).toBeGreaterThan(factoryIndex) + expect(pluginIndex).toBeGreaterThan(factoryEndIndex) + expect(initializedIndex).toBeGreaterThan(pluginIndex) + }) + + it('preserves constructor inputs and later service consumers', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const resolverIndex = source.indexOf('browserManager.setSettingsResolver', factoryIndex) + const constructorSource = source.slice(factoryIndex, resolverIndex) + + expect(constructorSource).toContain("homePath: app.getPath('home')") + expect(constructorSource).toContain( + 'getLegacyOverrides: () => store!.getSettings().keybindings' + ) + expect(constructorSource).toContain( + "isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending'" + ) + expect(constructorSource).toContain( + "store!.updateSettings({ tabSwitchKeybindingSeed: 'done' })" + ) + expect(source).toContain('getKeybindings: () => keybindings?.getOverrides()') + expect(source).toContain('keybindings,') + expect(source).toContain('getKeybindings: () => keybindings?.getOverrides() ?? {}') + expect(source).toContain( + "throw new Error('Keybinding service must be initialized before opening the main window')" + ) + }) +}) diff --git a/src/main/startup/keybinding-service-startup-capability.test.ts b/src/main/startup/keybinding-service-startup-capability.test.ts new file mode 100644 index 00000000000..4b5ce20b9b0 --- /dev/null +++ b/src/main/startup/keybinding-service-startup-capability.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../keybindings/keybinding-service', () => ({ + KeybindingService: class { + constructor(options: unknown) { + serviceMocks.constructor(options) + } + } +})) + +import { KeybindingService } from '../keybindings/keybinding-service' +import { createKeybindingServiceStartupCapability } from './keybinding-service-startup-capability' + +describe('keybinding service startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live service with the original options', async () => { + const options = { + homePath: '/Users/example', + platform: 'darwin' as const, + getLegacyOverrides: vi.fn(), + legacyTabSwitchSeed: { + isPending: vi.fn(), + markSeeded: vi.fn() + } + } + + const service = await createKeybindingServiceStartupCapability(options) + + expect(serviceMocks.constructor).toHaveBeenCalledOnce() + expect(serviceMocks.constructor).toHaveBeenCalledWith(options) + expect(service).toBeInstanceOf(KeybindingService) + }) +}) diff --git a/src/main/startup/keybinding-service-startup-capability.ts b/src/main/startup/keybinding-service-startup-capability.ts new file mode 100644 index 00000000000..6494c6c8899 --- /dev/null +++ b/src/main/startup/keybinding-service-startup-capability.ts @@ -0,0 +1,7 @@ +import { KeybindingService, type KeybindingServiceOptions } from '../keybindings/keybinding-service' + +export function createKeybindingServiceStartupCapability( + options: KeybindingServiceOptions +): KeybindingService { + return new KeybindingService(options) +} diff --git a/src/main/startup/main-window-startup-boundary.test.ts b/src/main/startup/main-window-startup-boundary.test.ts new file mode 100644 index 00000000000..c2a7ea565be --- /dev/null +++ b/src/main/startup/main-window-startup-boundary.test.ts @@ -0,0 +1,148 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const projectRoot = process.cwd() +const source = readFileSync(join(projectRoot, 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/main-window-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityInstall = 'installMainWindowStartupCapability(createMainWindowStartupCapability())' + +function listProductionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + return listProductionTypeScriptFiles(entryPath) + } + return entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.spec.ts') + ? [entryPath] + : [] + }) +} + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('main-window startup boundary', () => { + it('removes both eager target modules behind one dynamic aggregate capability', () => { + expect(findImport('./window/createMainWindow')).toBeUndefined() + expect(findImport('./window/attach-main-window-services')).toBeUndefined() + expect(findImport('./startup/main-window-startup-owner')).toBeDefined() + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityInstall)).toHaveLength(2) + }) + + it('keeps production value importers limited to deferred capability graphs', () => { + const productionSources = listProductionTypeScriptFiles(join(projectRoot, 'src/main')).map( + (filePath) => ({ filePath, source: readFileSync(filePath, 'utf8') }) + ) + const createWindowImporters = productionSources + .filter(({ source: fileSource }) => fileSource.includes("from '../window/createMainWindow'")) + .map(({ filePath }) => filePath) + const attachWindowImporters = productionSources + .filter(({ source: fileSource }) => + fileSource.includes("from '../window/attach-main-window-services'") + ) + .map(({ filePath }) => filePath) + + expect(createWindowImporters).toEqual([ + join(projectRoot, 'src/main/startup/main-window-startup-capability.ts') + ]) + expect(attachWindowImporters).toEqual([ + join(projectRoot, 'src/main/ipc/register-core-handlers.ts'), + join(projectRoot, 'src/main/startup/main-window-startup-capability.ts') + ]) + }) + + it('loads immediately after browser-kernel initialization at the earliest ready point', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const browserImportIndex = source.indexOf( + "await import('./startup/browser-kernel-startup-capability')", + readyIndex + ) + const browserFactoryIndex = source.indexOf( + 'const browserKernel = createBrowserKernelStartupCapability()', + browserImportIndex + ) + const windowImportIndex = source.indexOf(capabilityImport, browserFactoryIndex) + const installIndex = source.indexOf(capabilityInstall, windowImportIndex) + const browserManagerIndex = source.indexOf( + 'const browserManager = browserKernel.browserManager', + installIndex + ) + const watchdogIndex = source.indexOf('installMainThreadHangWatchdog(', browserManagerIndex) + + expect(browserImportIndex).toBeGreaterThan(readyIndex) + expect(browserFactoryIndex).toBeGreaterThan(browserImportIndex) + expect(windowImportIndex).toBeGreaterThan(browserFactoryIndex) + expect(installIndex).toBeGreaterThan(windowImportIndex) + expect(browserManagerIndex).toBeGreaterThan(installIndex) + expect(watchdogIndex).toBeGreaterThan(browserManagerIndex) + }) + + it('keeps predeclared consumers fail-closed and preserves synchronous create/attach/load order', () => { + const openStart = source.indexOf('function openMainWindow(): BrowserWindow {') + const openEnd = source.indexOf('\nfunction sendOpenFeatureTour', openStart) + const openWindow = source.slice(openStart, openEnd) + const ownerIndex = openWindow.indexOf('getMainWindowStartupCapability()') + const createIndex = openWindow.indexOf('const window = createMainWindow(') + const coreIpcIndex = openWindow.indexOf('registerCoreHandlers(') + const automationIndex = openWindow.indexOf('automations.start()', coreIpcIndex) + const attachIndex = openWindow.indexOf('attachMainWindowServices(', automationIndex) + const loadIndex = openWindow.indexOf('loadMainWindow(window)', attachIndex) + + expect(ownerIndex).toBeGreaterThanOrEqual(0) + expect(createIndex).toBeGreaterThan(ownerIndex) + expect(coreIpcIndex).toBeGreaterThan(createIndex) + expect(automationIndex).toBeGreaterThan(coreIpcIndex) + expect(attachIndex).toBeGreaterThan(automationIndex) + expect(loadIndex).toBeGreaterThan(attachIndex) + expect(openWindow).toContain('deferLoad: true') + }) + + it('preserves updater identity and manual crash-recovery reload through the owner', () => { + const updateStart = source.indexOf('function runUserInitiatedUpdateCheck(') + const updateEnd = source.indexOf('\nfunction getSystemTrayOptions', updateStart) + const updateBlock = source.slice(updateStart, updateEnd) + const recoveryStart = source.indexOf('async function presentRendererRecoveryPrompt(') + const recoveryEnd = source.indexOf('\nfunction getGpuFallbackEnvironment', recoveryStart) + const recoveryBlock = source.slice(recoveryStart, recoveryEnd) + + expect(updateBlock).toContain( + 'const { ensureAutoUpdaterConfigured } = getMainWindowStartupCapability()' + ) + expect(updateBlock.indexOf('ensureAutoUpdaterConfigured()')).toBeLessThan( + updateBlock.indexOf('checkForUpdatesFromMenu(options)') + ) + expect(recoveryBlock).toContain('const { loadMainWindow } = getMainWindowStartupCapability()') + expect(recoveryBlock).toContain('loadMainWindow(mainWindow)') + }) + + it('installs before activation, serve promotion, core IPC, and desktop parallel startup', () => { + const installIndex = source.indexOf(capabilityInstall) + const activateIndex = source.indexOf("app.on('activate', handleMacAppActivation)", installIndex) + const serveIndex = source.indexOf('if (serveOptions) {', activateIndex) + const settleServeIndex = source.indexOf('settleServeDesktopActivation()', serveIndex) + const coreIpcIndex = source.indexOf('await loadCoreIpcRegistryForDesktop()', settleServeIndex) + const parallelIndex = source.indexOf('const [win, runtimeRpcStartResult] = await Promise.all([') + const windowIndex = source.indexOf('Promise.resolve(openMainWindow())', parallelIndex) + const rpcIndex = source.indexOf('runtimeRpc.start().then(', windowIndex) + + expect(activateIndex).toBeGreaterThan(installIndex) + expect(serveIndex).toBeGreaterThan(activateIndex) + expect(settleServeIndex).toBeGreaterThan(serveIndex) + expect(coreIpcIndex).toBeGreaterThan(settleServeIndex) + expect(parallelIndex).toBeGreaterThan(coreIpcIndex) + expect(windowIndex).toBeGreaterThan(parallelIndex) + expect(rpcIndex).toBeGreaterThan(windowIndex) + }) +}) diff --git a/src/main/startup/main-window-startup-capability.test.ts b/src/main/startup/main-window-startup-capability.test.ts new file mode 100644 index 00000000000..8d0c9904ab8 --- /dev/null +++ b/src/main/startup/main-window-startup-capability.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +const windowMocks = vi.hoisted(() => ({ + attachMainWindowServices: vi.fn(), + createMainWindow: vi.fn(), + ensureAutoUpdaterConfigured: vi.fn(), + loadMainWindow: vi.fn() +})) + +vi.mock('../window/attach-main-window-services', () => ({ + attachMainWindowServices: windowMocks.attachMainWindowServices, + ensureAutoUpdaterConfigured: windowMocks.ensureAutoUpdaterConfigured +})) + +vi.mock('../window/createMainWindow', () => ({ + createMainWindow: windowMocks.createMainWindow, + loadMainWindow: windowMocks.loadMainWindow +})) + +import { createMainWindowStartupCapability } from './main-window-startup-capability' + +describe('main-window startup capability', () => { + it('returns all four original function identities', () => { + const capability = createMainWindowStartupCapability() + + expect(capability).toEqual({ + attachMainWindowServices: windowMocks.attachMainWindowServices, + createMainWindow: windowMocks.createMainWindow, + ensureAutoUpdaterConfigured: windowMocks.ensureAutoUpdaterConfigured, + loadMainWindow: windowMocks.loadMainWindow + }) + }) +}) diff --git a/src/main/startup/main-window-startup-capability.ts b/src/main/startup/main-window-startup-capability.ts new file mode 100644 index 00000000000..a20bcdc893a --- /dev/null +++ b/src/main/startup/main-window-startup-capability.ts @@ -0,0 +1,21 @@ +import { + attachMainWindowServices, + ensureAutoUpdaterConfigured +} from '../window/attach-main-window-services' +import { createMainWindow, loadMainWindow } from '../window/createMainWindow' + +export type MainWindowStartupCapability = { + attachMainWindowServices: typeof attachMainWindowServices + createMainWindow: typeof createMainWindow + ensureAutoUpdaterConfigured: typeof ensureAutoUpdaterConfigured + loadMainWindow: typeof loadMainWindow +} + +export function createMainWindowStartupCapability(): MainWindowStartupCapability { + return { + attachMainWindowServices, + createMainWindow, + ensureAutoUpdaterConfigured, + loadMainWindow + } +} diff --git a/src/main/startup/main-window-startup-owner.test.ts b/src/main/startup/main-window-startup-owner.test.ts new file mode 100644 index 00000000000..b063771de24 --- /dev/null +++ b/src/main/startup/main-window-startup-owner.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('main-window startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before the capability is installed', async () => { + const { getMainWindowStartupCapability } = await import('./main-window-startup-owner') + + expect(() => getMainWindowStartupCapability()).toThrow( + 'Main-window capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { getMainWindowStartupCapability, installMainWindowStartupCapability } = + await import('./main-window-startup-owner') + const capability = { + attachMainWindowServices: vi.fn(), + createMainWindow: vi.fn(), + ensureAutoUpdaterConfigured: vi.fn(), + loadMainWindow: vi.fn() + } + + installMainWindowStartupCapability(capability as never) + + expect(getMainWindowStartupCapability()).toBe(capability) + }) +}) diff --git a/src/main/startup/main-window-startup-owner.ts b/src/main/startup/main-window-startup-owner.ts new file mode 100644 index 00000000000..315fc33038b --- /dev/null +++ b/src/main/startup/main-window-startup-owner.ts @@ -0,0 +1,16 @@ +import type { MainWindowStartupCapability } from './main-window-startup-capability' + +let capability: MainWindowStartupCapability | null = null + +export function installMainWindowStartupCapability( + nextCapability: MainWindowStartupCapability +): void { + capability = nextCapability +} + +export function getMainWindowStartupCapability(): MainWindowStartupCapability { + if (!capability) { + throw new Error('Main-window capability must be initialized before use') + } + return capability +} diff --git a/src/main/startup/offscreen-browser-startup-boundary.test.ts b/src/main/startup/offscreen-browser-startup-boundary.test.ts new file mode 100644 index 00000000000..24783a82045 --- /dev/null +++ b/src/main/startup/offscreen-browser-startup-boundary.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/offscreen-browser-startup-capability')" +const capabilityAttach = 'attachOffscreenBrowserStartupCapability(runtime, browserManager)' + +describe('offscreen browser startup boundary', () => { + it('keeps the backend implementation out of the eager main graph', () => { + const staticImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .map((declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text) + + expect(staticImports).not.toContain('./browser/offscreen-browser-backend') + expect(source).not.toContain('new OffscreenBrowserBackend(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityAttach)).toHaveLength(2) + }) + + it('loads only within both serve and display gates', () => { + const serveGateIndex = source.indexOf('if (serveOptions) {') + const displayGateIndex = source.indexOf( + 'if (headlessBrowserDisplayAvailable) {', + serveGateIndex + ) + const importIndex = source.indexOf(capabilityImport, displayGateIndex) + const attachIndex = source.indexOf(capabilityAttach, importIndex) + const displayGateEndIndex = source.indexOf( + '\n // Why: headless servers have no renderer graph publisher;', + attachIndex + ) + + expect(serveGateIndex).toBeGreaterThanOrEqual(0) + expect(displayGateIndex).toBeGreaterThan(serveGateIndex) + expect(importIndex).toBeGreaterThan(displayGateIndex) + expect(attachIndex).toBeGreaterThan(importIndex) + expect(displayGateEndIndex).toBeGreaterThan(attachIndex) + }) + + it('attaches before graph sync, RPC startup, activation, and readiness', () => { + const attachIndex = source.indexOf(capabilityAttach) + const graphSyncIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + attachIndex + ) + const rpcStartIndex = source.indexOf('await runtimeRpc.start()', graphSyncIndex) + const activationIndex = source.indexOf('settleServeDesktopActivation()', rpcStartIndex) + const readyIndex = source.indexOf('await printServeReady(serveOptions)', activationIndex) + + expect(attachIndex).toBeGreaterThanOrEqual(0) + expect(graphSyncIndex).toBeGreaterThan(attachIndex) + expect(rpcStartIndex).toBeGreaterThan(graphSyncIndex) + expect(activationIndex).toBeGreaterThan(rpcStartIndex) + expect(readyIndex).toBeGreaterThan(activationIndex) + }) + + it('keeps desktop startup outside the capability and committed teardown unchanged', () => { + const serveReadyIndex = source.indexOf('await printServeReady(serveOptions)') + const serveReturnIndex = source.indexOf('\n return\n', serveReadyIndex) + const desktopWindowIndex = source.indexOf('Promise.resolve(openMainWindow())', serveReturnIndex) + const desktopRpcIndex = source.indexOf('runtimeRpc.start().then(', desktopWindowIndex) + const teardownIndex = source.indexOf('runtime?.getOffscreenBrowserBackend()?.destroyAll?.()') + + expect(serveReturnIndex).toBeGreaterThan(serveReadyIndex) + expect(desktopWindowIndex).toBeGreaterThan(serveReturnIndex) + expect(desktopRpcIndex).toBeGreaterThan(desktopWindowIndex) + expect(source.indexOf(capabilityImport, serveReturnIndex)).toBe(-1) + expect(source).toContain("app.on('will-quit', (e) => {") + expect(teardownIndex).toBeGreaterThan(source.indexOf("app.on('will-quit', (e) => {")) + }) +}) diff --git a/src/main/startup/offscreen-browser-startup-capability.test.ts b/src/main/startup/offscreen-browser-startup-capability.test.ts new file mode 100644 index 00000000000..4a7ccb5c276 --- /dev/null +++ b/src/main/startup/offscreen-browser-startup-capability.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +const backendMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../browser/offscreen-browser-backend', () => ({ + OffscreenBrowserBackend: class { + constructor(browserManager: unknown) { + backendMocks.constructor(browserManager) + } + } +})) + +import type { BrowserManager } from '../browser/browser-manager' +import { attachOffscreenBrowserStartupCapability } from './offscreen-browser-startup-capability' + +describe('offscreen browser startup capability', () => { + it('constructs with the original manager and attaches the same live backend', () => { + const browserManager = {} as BrowserManager + const setOffscreenBrowserBackend = vi.fn() + + const backend = attachOffscreenBrowserStartupCapability( + { setOffscreenBrowserBackend }, + browserManager + ) + + expect(backendMocks.constructor).toHaveBeenCalledOnce() + expect(backendMocks.constructor).toHaveBeenCalledWith(browserManager) + expect(setOffscreenBrowserBackend).toHaveBeenCalledOnce() + expect(setOffscreenBrowserBackend).toHaveBeenCalledWith(backend) + }) +}) diff --git a/src/main/startup/offscreen-browser-startup-capability.ts b/src/main/startup/offscreen-browser-startup-capability.ts new file mode 100644 index 00000000000..4471a2de390 --- /dev/null +++ b/src/main/startup/offscreen-browser-startup-capability.ts @@ -0,0 +1,14 @@ +import type { BrowserManager } from '../browser/browser-manager' +import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type OffscreenBrowserStartupRuntime = Pick + +export function attachOffscreenBrowserStartupCapability( + runtime: OffscreenBrowserStartupRuntime, + browserManager: BrowserManager +): OffscreenBrowserBackend { + const backend = new OffscreenBrowserBackend(browserManager) + runtime.setOffscreenBrowserBackend(backend) + return backend +} diff --git a/src/main/startup/opencode-usage-store-startup-boundary.test.ts b/src/main/startup/opencode-usage-store-startup-boundary.test.ts new file mode 100644 index 00000000000..80d491a5cdb --- /dev/null +++ b/src/main/startup/opencode-usage-store-startup-boundary.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/opencode-usage-store-startup-capability')" +const capabilityFactory = 'openCodeUsage = await createOpenCodeUsageStoreStartupCapability(store)' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('OpenCode usage store startup boundary', () => { + it('keeps only the path capture in the eager main graph', () => { + expect(findImport('./opencode-usage/store')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./opencode-usage/opencode-usage-file-path')?.importClause?.isTypeOnly).toBe( + false + ) + expect(source).not.toContain('new OpenCodeUsageStore(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('captures the path before app naming and constructs at the former services point', () => { + const dataPathIndex = source.indexOf('initDataPath()') + const usagePathIndex = source.indexOf('initOpenCodeUsagePath()', dataPathIndex) + const whenReadyIndex = source.indexOf('void app.whenReady()') + const appNameIndex = source.indexOf('app.setName(', whenReadyIndex) + const codexUsageIndex = source.indexOf('codexUsage = new CodexUsageStore(store)') + const importIndex = source.indexOf(capabilityImport, codexUsageIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const rateLimitsIndex = source.indexOf('rateLimits = new RateLimitService()', factoryIndex) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(dataPathIndex).toBeGreaterThanOrEqual(0) + expect(usagePathIndex).toBeGreaterThan(dataPathIndex) + expect(whenReadyIndex).toBeGreaterThan(usagePathIndex) + expect(appNameIndex).toBeGreaterThan(whenReadyIndex) + expect(importIndex).toBeGreaterThan(codexUsageIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(rateLimitsIndex).toBeGreaterThan(factoryIndex) + expect(initializedIndex).toBeGreaterThan(rateLimitsIndex) + }) + + it('preserves the readiness guard and core-handler singleton identity', () => { + expect(source).toContain( + "throw new Error('OpenCode usage store must be initialized before opening the main window')" + ) + const handlersStart = source.indexOf('registerCoreHandlers(') + const handlersEnd = source.indexOf( + ')', + source.indexOf('pluginMarketplaceService', handlersStart) + ) + const handlersSource = source.slice(handlersStart, handlersEnd) + + expect(handlersStart).toBeGreaterThanOrEqual(0) + expect(handlersEnd).toBeGreaterThan(handlersStart) + expect(handlersSource).toContain('openCodeUsage,') + expect(source.split(capabilityFactory)).toHaveLength(2) + }) +}) diff --git a/src/main/startup/opencode-usage-store-startup-capability.test.ts b/src/main/startup/opencode-usage-store-startup-capability.test.ts new file mode 100644 index 00000000000..17b18c87d51 --- /dev/null +++ b/src/main/startup/opencode-usage-store-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const storeMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../opencode-usage/store', () => ({ + OpenCodeUsageStore: class { + constructor(store: unknown) { + storeMocks.constructor(store) + } + } +})) + +import { OpenCodeUsageStore } from '../opencode-usage/store' +import { createOpenCodeUsageStoreStartupCapability } from './opencode-usage-store-startup-capability' + +describe('OpenCode usage store startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live store with the original Store input', async () => { + const store = { getRepos: vi.fn() } + + const usageStore = await createOpenCodeUsageStoreStartupCapability(store as never) + + expect(storeMocks.constructor).toHaveBeenCalledOnce() + expect(storeMocks.constructor).toHaveBeenCalledWith(store) + expect(usageStore).toBeInstanceOf(OpenCodeUsageStore) + }) +}) diff --git a/src/main/startup/opencode-usage-store-startup-capability.ts b/src/main/startup/opencode-usage-store-startup-capability.ts new file mode 100644 index 00000000000..beef0adf344 --- /dev/null +++ b/src/main/startup/opencode-usage-store-startup-capability.ts @@ -0,0 +1,9 @@ +import { OpenCodeUsageStore } from '../opencode-usage/store' + +type OpenCodeUsageStoreParameters = ConstructorParameters + +export function createOpenCodeUsageStoreStartupCapability( + store: OpenCodeUsageStoreParameters[0] +): OpenCodeUsageStore { + return new OpenCodeUsageStore(store) +} diff --git a/src/main/startup/plugin-system-startup-boundary.test.ts b/src/main/startup/plugin-system-startup-boundary.test.ts new file mode 100644 index 00000000000..7c134f39fe9 --- /dev/null +++ b/src/main/startup/plugin-system-startup-boundary.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/plugin-system-startup-capability')" +const capabilityFactory = 'const pluginSystem = await createPluginSystemStartupCapability(' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('plugin system startup boundary', () => { + it('keeps all five implementations and startup-only resolvers out of the eager main graph', () => { + const serviceModules = [ + './plugins/plugin-service', + './plugins/plugin-kill-list-service', + './plugins/plugin-marketplace-service', + './plugins/plugin-marketplace-installer', + './plugins/plugin-bundled-bootstrap-coordinator' + ] + + for (const moduleSpecifier of serviceModules) { + expect(findImport(moduleSpecifier)?.importClause?.isTypeOnly).toBe(true) + } + for (const moduleSpecifier of [ + './plugins/plugin-discovery', + './plugins/plugin-bundled-bootstrap', + './plugins/plugin-host-process', + '../shared/plugins/plugin-consent-state' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + expect(source).not.toMatch( + /new (PluginService|PluginKillListService|PluginMarketplaceService|PluginMarketplaceInstaller|PluginBundledBootstrapCoordinator)\(/ + ) + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('assigns every returned singleton before plugin wiring and initialization', () => { + const importIndex = source.indexOf(capabilityImport) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const assignments = [ + 'pluginKillListService = pluginSystem.killList', + 'pluginMarketplaceService = pluginSystem.marketplace', + 'pluginMarketplaceInstaller = pluginSystem.marketplaceInstaller', + 'pluginService = pluginSystem.pluginService', + 'const bundledPluginBootstrap: PluginBundledBootstrapCoordinator = pluginSystem.bundledBootstrap' + ] + let previousIndex = factoryIndex + for (const assignment of assignments) { + const assignmentIndex = source.indexOf(assignment, previousIndex) + expect(assignmentIndex).toBeGreaterThan(previousIndex) + previousIndex = assignmentIndex + } + + const killListListenerIndex = source.indexOf('pluginKillListService.onChanged(', previousIndex) + const settingsListenerIndex = source.indexOf('store.onSettingsChanged(', killListListenerIndex) + const rpcIndex = source.indexOf( + 'setPluginServiceForRpc(pluginService, {', + settingsListenerIndex + ) + const initializeIndex = source.indexOf('.initialize()', rpcIndex) + const initializedMilestoneIndex = source.indexOf( + "logStartupMilestone('services-initialized')", + initializeIndex + ) + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(killListListenerIndex).toBeGreaterThan(previousIndex) + expect(settingsListenerIndex).toBeGreaterThan(killListListenerIndex) + expect(rpcIndex).toBeGreaterThan(settingsListenerIndex) + expect(initializeIndex).toBeGreaterThan(rpcIndex) + expect(initializedMilestoneIndex).toBeGreaterThan(initializeIndex) + }) + + it('keeps feature gates, bootstrap timing, broadcasts, and event taps in index', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const settingsGateIndex = source.indexOf( + 'if (store?.getSettings().pluginSystemEnabled !== true)', + factoryIndex + ) + const packagedRefreshIndex = source.indexOf( + 'if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true)', + settingsGateIndex + ) + const changedIndex = source.indexOf( + 'pluginService.onChanged((event) => {', + packagedRefreshIndex + ) + const languagePackIndex = source.indexOf('setMainPluginLanguagePacks(', changedIndex) + const rendererBroadcastIndex = source.indexOf( + "window.webContents.send('plugins:changed', event)", + languagePackIndex + ) + const bootstrapIndex = source.indexOf('requestBundledPluginBootstrap()', rendererBroadcastIndex) + const marketplaceIndex = source.indexOf('requestOfficialMarketplaceSeed()', bootstrapIndex) + const agentEventIndex = source.indexOf( + "pluginService?.emitEvent('agent.status.changed'", + marketplaceIndex + ) + const worktreeEventIndex = source.indexOf('emitPluginWorktreeLifecycle(event)', agentEventIndex) + + expect(settingsGateIndex).toBeGreaterThan(factoryIndex) + expect(packagedRefreshIndex).toBeGreaterThan(settingsGateIndex) + expect(changedIndex).toBeGreaterThan(packagedRefreshIndex) + expect(languagePackIndex).toBeGreaterThan(changedIndex) + expect(rendererBroadcastIndex).toBeGreaterThan(languagePackIndex) + expect(bootstrapIndex).toBeGreaterThan(rendererBroadcastIndex) + expect(marketplaceIndex).toBeGreaterThan(bootstrapIndex) + expect(agentEventIndex).toBeGreaterThan(marketplaceIndex) + expect(worktreeEventIndex).toBeGreaterThan(agentEventIndex) + }) + + it('keeps core IPC identity, window readiness, RPC startup, and teardown unchanged', () => { + const coreHandlersIndex = source.indexOf('registerCoreHandlers(') + const corePluginIndex = source.indexOf('pluginService ?? undefined,', coreHandlersIndex) + const coreMarketplaceIndex = source.indexOf( + 'pluginMarketplaceService && pluginMarketplaceInstaller', + corePluginIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + const desktopWindowIndex = source.indexOf('Promise.resolve(openMainWindow())', initializedIndex) + const desktopRpcIndex = source.indexOf('runtimeRpc.start().then(', desktopWindowIndex) + const clearRpcIndex = source.indexOf('setPluginServiceForRpc(null)') + const clearKillListIndex = source.indexOf('pluginKillListService = null', clearRpcIndex) + const clearMarketplaceIndex = source.indexOf( + 'pluginMarketplaceService = null', + clearKillListIndex + ) + const clearInstallerIndex = source.indexOf( + 'pluginMarketplaceInstaller = null', + clearMarketplaceIndex + ) + const disposeIndex = source.indexOf( + 'const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()', + clearInstallerIndex + ) + const clearPluginIndex = source.indexOf('pluginService = null', disposeIndex) + + expect(corePluginIndex).toBeGreaterThan(coreHandlersIndex) + expect(coreMarketplaceIndex).toBeGreaterThan(corePluginIndex) + expect(desktopWindowIndex).toBeGreaterThan(initializedIndex) + expect(desktopRpcIndex).toBeGreaterThan(desktopWindowIndex) + expect(clearKillListIndex).toBeGreaterThan(clearRpcIndex) + expect(clearMarketplaceIndex).toBeGreaterThan(clearKillListIndex) + expect(clearInstallerIndex).toBeGreaterThan(clearMarketplaceIndex) + expect(disposeIndex).toBeGreaterThan(clearInstallerIndex) + expect(clearPluginIndex).toBeGreaterThan(disposeIndex) + expect(source).toContain("{ name: 'plugin-hosts', promise: pluginHostShutdown }") + }) +}) diff --git a/src/main/startup/plugin-system-startup-capability.test.ts b/src/main/startup/plugin-system-startup-capability.test.ts new file mode 100644 index 00000000000..f0d4a7d1fd1 --- /dev/null +++ b/src/main/startup/plugin-system-startup-capability.test.ts @@ -0,0 +1,177 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + events: [] as string[], + killListConstructor: vi.fn(), + marketplaceConstructor: vi.fn(), + installerConstructor: vi.fn(), + pluginServiceConstructor: vi.fn(), + bundledBootstrapConstructor: vi.fn() +})) + +vi.mock('../plugins/plugin-discovery', () => ({ + getPluginsDataDir: (userDataPath: string) => join(userDataPath, 'plugins-data') +})) + +vi.mock('../plugins/plugin-host-process', () => ({ + resolvePluginHostEntryPath: (appPath: string, isPackaged: boolean) => + join(appPath, `${isPackaged ? 'packaged' : 'development'}-plugin-host.js`) +})) + +vi.mock('../plugins/plugin-bundled-bootstrap', () => ({ + resolveBundledPluginRoot: (options: { + isPackaged: boolean + resourcesPath: string + appPath: string + }) => join(options.isPackaged ? options.resourcesPath : options.appPath, 'bundled-plugins') +})) + +vi.mock('../plugins/plugin-kill-list-service', () => ({ + PluginKillListService: class { + constructor(options: unknown) { + serviceMocks.events.push('kill-list') + serviceMocks.killListConstructor(this, options) + } + + async initialize(): Promise { + serviceMocks.events.push('kill-list-initialize') + } + } +})) + +vi.mock('../plugins/plugin-marketplace-service', () => ({ + PluginMarketplaceService: class { + constructor(options: unknown) { + serviceMocks.events.push('marketplace') + serviceMocks.marketplaceConstructor(this, options) + } + } +})) + +vi.mock('../plugins/plugin-marketplace-installer', () => ({ + PluginMarketplaceInstaller: class { + constructor(options: unknown) { + serviceMocks.events.push('marketplace-installer') + serviceMocks.installerConstructor(this, options) + } + } +})) + +vi.mock('../plugins/plugin-service', () => ({ + PluginService: class { + constructor(options: unknown) { + serviceMocks.events.push('plugin-service') + serviceMocks.pluginServiceConstructor(this, options) + } + } +})) + +vi.mock('../plugins/plugin-bundled-bootstrap-coordinator', () => ({ + PluginBundledBootstrapCoordinator: class { + constructor(options: unknown) { + serviceMocks.events.push('bundled-bootstrap') + serviceMocks.bundledBootstrapConstructor(this, options) + } + } +})) + +import type { GlobalSettings } from '../../shared/types' +import { createPluginSystemStartupCapability } from './plugin-system-startup-capability' + +const platform = { + userDataPath: join('profile', 'user-data'), + hostVersion: '1.2.3', + appPath: join('application', 'root'), + resourcesPath: join('application', 'resources'), + isPackaged: true +} + +function createBindings(settings: Partial = {}) { + return { + getSettings: () => + ({ + pluginSystemEnabled: true, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], + ...settings + }) as GlobalSettings, + getKeybindings: vi.fn(() => ({ 'app.newTerminal': ['CmdOrCtrl+T'] })), + getKillListEntry: vi.fn(() => null), + getBlockedPluginReason: vi.fn(() => null), + refreshPlugins: vi.fn(async () => undefined) + } +} + +describe('plugin system startup capability', () => { + beforeEach(() => { + serviceMocks.events.length = 0 + vi.clearAllMocks() + }) + + it('preserves the kill-list barrier and exact aggregate construction order', async () => { + const services = await createPluginSystemStartupCapability(platform, createBindings()) + + expect(serviceMocks.events).toEqual([ + 'kill-list', + 'kill-list-initialize', + 'marketplace', + 'marketplace-installer', + 'plugin-service', + 'bundled-bootstrap' + ]) + expect(serviceMocks.killListConstructor).toHaveBeenCalledWith(services.killList, { + pluginsDataDir: join(platform.userDataPath, 'plugins-data') + }) + expect(serviceMocks.marketplaceConstructor.mock.calls[0]?.[0]).toBe(services.marketplace) + expect(serviceMocks.installerConstructor.mock.calls[0]?.[0]).toBe(services.marketplaceInstaller) + expect(serviceMocks.pluginServiceConstructor.mock.calls[0]?.[0]).toBe(services.pluginService) + expect(serviceMocks.bundledBootstrapConstructor.mock.calls[0]?.[0]).toBe( + services.bundledBootstrap + ) + }) + + it('preserves platform values, dependency identity, and live policy callbacks', async () => { + const bindings = createBindings({ + disabledPlugins: ['official.one', 'official.one'], + pluginConsents: { 'official.one': 'fingerprint' }, + devPluginPaths: ['/dev/plugin', '/dev/plugin'] + }) + const services = await createPluginSystemStartupCapability(platform, bindings) + const marketplaceOptions = serviceMocks.marketplaceConstructor.mock.calls[0]?.[1] + const installerOptions = serviceMocks.installerConstructor.mock.calls[0]?.[1] + const pluginOptions = serviceMocks.pluginServiceConstructor.mock.calls[0]?.[1] + const bootstrapOptions = serviceMocks.bundledBootstrapConstructor.mock.calls[0]?.[1] + + expect(marketplaceOptions).toMatchObject({ + pluginsDataDir: join(platform.userDataPath, 'plugins-data'), + getKillListEntry: bindings.getKillListEntry + }) + expect(installerOptions).toMatchObject({ + marketplace: services.marketplace, + userDataPath: platform.userDataPath, + hostVersion: '1.2.3', + blockedPluginReason: bindings.getBlockedPluginReason + }) + expect(pluginOptions).toMatchObject({ + userDataPath: platform.userDataPath, + hostVersion: '1.2.3', + getKeybindings: bindings.getKeybindings, + getPluginKillListEntry: bindings.getKillListEntry, + hostEntryPath: join(platform.appPath, 'packaged-plugin-host.js') + }) + expect(pluginOptions.isPluginSystemEnabled()).toBe(true) + expect(pluginOptions.getDisabledPlugins()).toEqual(['official.one']) + expect(pluginOptions.getPluginConsents()).toEqual({ 'official.one': 'fingerprint' }) + expect(pluginOptions.getDevPluginPaths()).toEqual(['/dev/plugin']) + expect(bootstrapOptions).toMatchObject({ + root: join(platform.resourcesPath, 'bundled-plugins'), + userDataPath: platform.userDataPath, + hostVersion: '1.2.3', + blockedPluginReason: bindings.getBlockedPluginReason, + refreshPlugins: bindings.refreshPlugins + }) + expect(bootstrapOptions.isEnabled()).toBe(true) + }) +}) diff --git a/src/main/startup/plugin-system-startup-capability.ts b/src/main/startup/plugin-system-startup-capability.ts new file mode 100644 index 00000000000..0c1f6de34e1 --- /dev/null +++ b/src/main/startup/plugin-system-startup-capability.ts @@ -0,0 +1,89 @@ +import type { KeybindingOverrides } from '../../shared/keybindings' +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' +import type { GlobalSettings } from '../../shared/types' +import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' +import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' +import { getPluginsDataDir } from '../plugins/plugin-discovery' +import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { PluginKillListService } from '../plugins/plugin-kill-list-service' +import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PluginService } from '../plugins/plugin-service' + +type PluginStartupSettings = Pick< + GlobalSettings, + 'pluginSystemEnabled' | 'disabledPlugins' | 'pluginConsents' | 'devPluginPaths' +> + +type PluginSystemStartupPlatform = { + userDataPath: string + hostVersion: string + appPath: string + resourcesPath: string + isPackaged: boolean +} + +type PluginSystemStartupBindings = { + getSettings: () => PluginStartupSettings + getKeybindings: () => KeybindingOverrides + getKillListEntry: (pluginKey: string) => PluginKillListEntry | null + getBlockedPluginReason: (pluginKey: string) => string | null + refreshPlugins: () => Promise +} + +export type PluginSystemStartupCapability = { + killList: PluginKillListService + marketplace: PluginMarketplaceService + marketplaceInstaller: PluginMarketplaceInstaller + pluginService: PluginService + bundledBootstrap: PluginBundledBootstrapCoordinator +} + +export async function createPluginSystemStartupCapability( + platform: PluginSystemStartupPlatform, + bindings: PluginSystemStartupBindings +): Promise { + const killList = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(platform.userDataPath) + }) + await killList.initialize() + const marketplace = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(platform.userDataPath), + getKillListEntry: bindings.getKillListEntry + }) + const marketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace, + userDataPath: platform.userDataPath, + hostVersion: platform.hostVersion, + blockedPluginReason: bindings.getBlockedPluginReason + }) + const pluginService = new PluginService({ + userDataPath: platform.userDataPath, + hostVersion: platform.hostVersion, + isPluginSystemEnabled: () => bindings.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(bindings.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(bindings.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(bindings.getSettings().devPluginPaths), + getKeybindings: bindings.getKeybindings, + getPluginKillListEntry: bindings.getKillListEntry, + hostEntryPath: resolvePluginHostEntryPath(platform.appPath, platform.isPackaged) + }) + const bundledBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: platform.isPackaged, + resourcesPath: platform.resourcesPath, + appPath: platform.appPath + }), + userDataPath: platform.userDataPath, + hostVersion: platform.hostVersion, + isEnabled: () => bindings.getSettings().pluginSystemEnabled === true, + blockedPluginReason: bindings.getBlockedPluginReason, + refreshPlugins: bindings.refreshPlugins + }) + + return { killList, marketplace, marketplaceInstaller, pluginService, bundledBootstrap } +} diff --git a/src/main/startup/runtime-connectivity-startup-boundary.test.ts b/src/main/startup/runtime-connectivity-startup-boundary.test.ts new file mode 100644 index 00000000000..b6d3a499223 --- /dev/null +++ b/src/main/startup/runtime-connectivity-startup-boundary.test.ts @@ -0,0 +1,123 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/runtime-connectivity-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('runtime connectivity startup boundary', () => { + it('moves only the app-ready connectivity leaves behind one owner', () => { + for (const moduleSpecifier of [ + './ipc/mobile', + './runtime/agent-session-claim-identity', + './ipc/runtime-environment-transport-routing', + '../shared/runtime-environment-store', + '../shared/runtime-environments', + './runtime/pairing-endpoint' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect( + findImport('./runtime/orchestration/environment-transport')?.importClause?.isTypeOnly + ).toBe(true) + expect(findImport('./startup/runtime-connectivity-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs before every connectivity consumer', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const importIndex = source.indexOf(capabilityImport, readyIndex) + const factoryIndex = source.indexOf( + 'const runtimeConnectivity = createRuntimeConnectivityStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installRuntimeConnectivityStartupCapability(runtimeConnectivity)', + factoryIndex + ) + const runtimeTransportIndex = source.indexOf( + 'const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {', + installIndex + ) + const signerIndex = source.indexOf( + 'agentSessionClaimSigner: runtimeConnectivity.loadAgentSessionClaimSigner(', + runtimeTransportIndex + ) + const mobileIndex = source.indexOf( + 'runtimeConnectivity.registerMobileHandlers(runtimeRpc, {', + signerIndex + ) + const serveReadyIndex = source.indexOf('await printServeReady(serveOptions)', mobileIndex) + + expect(importIndex).toBeGreaterThan(readyIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(runtimeTransportIndex).toBeGreaterThan(installIndex) + expect(signerIndex).toBeGreaterThan(runtimeTransportIndex) + expect(mobileIndex).toBeGreaterThan(signerIndex) + expect(serveReadyIndex).toBeGreaterThan(mobileIndex) + }) + + it('preserves the original runtime transport and mobile option expressions', () => { + const transportIndex = source.indexOf( + 'const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {' + ) + const runtimeFactoryIndex = source.indexOf( + 'const runtimeService = createOrcaRuntimeServiceStartupCapability(', + transportIndex + ) + const transport = source.slice(transportIndex, runtimeFactoryIndex) + const runtimeRpcIndex = source.indexOf( + 'runtimeRpc = createOrcaRuntimeRpcServerStartupCapability({' + ) + const mobileIndex = source.indexOf( + 'runtimeConnectivity.registerMobileHandlers(runtimeRpc, {', + runtimeRpcIndex + ) + const mobileEnd = source.indexOf( + 'runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {', + mobileIndex + ) + const mobile = source.slice(mobileIndex, mobileEnd) + + expect(transport).toContain("app.getPath('userData'), selector") + expect(transport).toContain('pairing.publicKeyB64') + expect(transport).toContain('timeoutMs,') + expect(transport).toContain('undefined,') + expect(transport).toContain('envelope') + expect(mobile).toContain('getRelayStatus: () => desktopRelayStatus') + expect(mobile).toContain('consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {') + }) + + it('keeps bootstrap, platform policy, and shutdown ownership eager', () => { + for (const moduleSpecifier of [ + './startup/configure-process', + './startup/gpu-fallback-marker', + '../shared/cross-platform-path', + './wsl', + './agent-auth-restart-preservation', + './runtime/orca-runtime-files', + './runtime/runtime-metadata' + ]) { + expect(findImport(moduleSpecifier)).toBeDefined() + } + + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const preReady = source.slice(0, readyIndex) + expect(preReady).not.toContain('runtimeConnectivity.') + expect(preReady).not.toContain('createRuntimeConnectivityStartupCapability()') + }) +}) diff --git a/src/main/startup/runtime-connectivity-startup-capability.test.ts b/src/main/startup/runtime-connectivity-startup-capability.test.ts new file mode 100644 index 00000000000..6a06a409815 --- /dev/null +++ b/src/main/startup/runtime-connectivity-startup-capability.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +const connectivityMocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + fingerprintOrchestrationPeer: vi.fn(), + getPreferredPairingOffer: vi.fn(), + loadAgentSessionClaimSigner: vi.fn(), + registerMobileHandlers: vi.fn(), + resolveAdvertisedPairingEndpoint: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('../ipc/mobile', () => ({ + registerMobileHandlers: connectivityMocks.registerMobileHandlers +})) +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: connectivityMocks.callRuntimeEnvironment +})) +vi.mock('../runtime/agent-session-claim-identity', () => ({ + loadAgentSessionClaimSigner: connectivityMocks.loadAgentSessionClaimSigner +})) +vi.mock('../runtime/orchestration/environment-transport', () => ({ + fingerprintOrchestrationPeer: connectivityMocks.fingerprintOrchestrationPeer +})) +vi.mock('../runtime/pairing-endpoint', () => ({ + resolveAdvertisedPairingEndpoint: connectivityMocks.resolveAdvertisedPairingEndpoint +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: connectivityMocks.resolveEnvironment +})) +vi.mock('../../shared/runtime-environments', () => ({ + getPreferredPairingOffer: connectivityMocks.getPreferredPairingOffer +})) + +import { createRuntimeConnectivityStartupCapability } from './runtime-connectivity-startup-capability' + +describe('runtime connectivity startup capability', () => { + it('returns every original function identity', () => { + expect(createRuntimeConnectivityStartupCapability()).toEqual(connectivityMocks) + }) +}) diff --git a/src/main/startup/runtime-connectivity-startup-capability.ts b/src/main/startup/runtime-connectivity-startup-capability.ts new file mode 100644 index 00000000000..28ec05cc0c8 --- /dev/null +++ b/src/main/startup/runtime-connectivity-startup-capability.ts @@ -0,0 +1,29 @@ +import { registerMobileHandlers } from '../ipc/mobile' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' +import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' + +export type RuntimeConnectivityStartupCapability = { + callRuntimeEnvironment: typeof callRuntimeEnvironment + fingerprintOrchestrationPeer: typeof fingerprintOrchestrationPeer + getPreferredPairingOffer: typeof getPreferredPairingOffer + loadAgentSessionClaimSigner: typeof loadAgentSessionClaimSigner + registerMobileHandlers: typeof registerMobileHandlers + resolveAdvertisedPairingEndpoint: typeof resolveAdvertisedPairingEndpoint + resolveEnvironment: typeof resolveEnvironment +} + +export function createRuntimeConnectivityStartupCapability(): RuntimeConnectivityStartupCapability { + return { + callRuntimeEnvironment, + fingerprintOrchestrationPeer, + getPreferredPairingOffer, + loadAgentSessionClaimSigner, + registerMobileHandlers, + resolveAdvertisedPairingEndpoint, + resolveEnvironment + } +} diff --git a/src/main/startup/runtime-connectivity-startup-owner.test.ts b/src/main/startup/runtime-connectivity-startup-owner.test.ts new file mode 100644 index 00000000000..0536321c0eb --- /dev/null +++ b/src/main/startup/runtime-connectivity-startup-owner.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('runtime connectivity startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation', async () => { + const { getRuntimeConnectivityStartupCapability } = + await import('./runtime-connectivity-startup-owner') + + expect(() => getRuntimeConnectivityStartupCapability()).toThrow( + 'Runtime connectivity capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { getRuntimeConnectivityStartupCapability, installRuntimeConnectivityStartupCapability } = + await import('./runtime-connectivity-startup-owner') + const capability = { + callRuntimeEnvironment: vi.fn(), + fingerprintOrchestrationPeer: vi.fn(), + getPreferredPairingOffer: vi.fn(), + loadAgentSessionClaimSigner: vi.fn(), + registerMobileHandlers: vi.fn(), + resolveAdvertisedPairingEndpoint: vi.fn(), + resolveEnvironment: vi.fn() + } + + installRuntimeConnectivityStartupCapability(capability as never) + + expect(getRuntimeConnectivityStartupCapability()).toBe(capability) + }) +}) diff --git a/src/main/startup/runtime-connectivity-startup-owner.ts b/src/main/startup/runtime-connectivity-startup-owner.ts new file mode 100644 index 00000000000..041330f7b20 --- /dev/null +++ b/src/main/startup/runtime-connectivity-startup-owner.ts @@ -0,0 +1,16 @@ +import type { RuntimeConnectivityStartupCapability } from './runtime-connectivity-startup-capability' + +let capability: RuntimeConnectivityStartupCapability | null = null + +export function installRuntimeConnectivityStartupCapability( + nextCapability: RuntimeConnectivityStartupCapability +): void { + capability = nextCapability +} + +export function getRuntimeConnectivityStartupCapability(): RuntimeConnectivityStartupCapability { + if (!capability) { + throw new Error('Runtime connectivity capability must be initialized before use') + } + return capability +} diff --git a/src/main/startup/runtime-rpc-server-startup-boundary.test.ts b/src/main/startup/runtime-rpc-server-startup-boundary.test.ts new file mode 100644 index 00000000000..1781e6f097c --- /dev/null +++ b/src/main/startup/runtime-rpc-server-startup-boundary.test.ts @@ -0,0 +1,148 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const projectRoot = process.cwd() +const source = readFileSync(join(projectRoot, 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/runtime-rpc-server-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = 'runtimeRpc = createOrcaRuntimeRpcServerStartupCapability({' + +function listProductionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + return listProductionTypeScriptFiles(entryPath) + } + return entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.spec.ts') + ? [entryPath] + : [] + }) +} + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('runtime RPC server startup boundary', () => { + it('keeps index type-only with one dynamic aggregate capability', () => { + expect(findImport('./runtime/runtime-rpc')?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new OrcaRuntimeRpcServer(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('keeps the sole production constructor and value import in the capability', () => { + const capabilityPath = join( + projectRoot, + 'src/main/startup/runtime-rpc-server-startup-capability.ts' + ) + const productionSources = listProductionTypeScriptFiles(join(projectRoot, 'src/main')).map( + (filePath) => ({ filePath, source: readFileSync(filePath, 'utf8') }) + ) + const valueImporters = productionSources + .filter(({ source: fileSource }) => + fileSource.includes("import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'") + ) + .map(({ filePath }) => filePath) + const constructors = productionSources + .filter(({ source: fileSource }) => fileSource.includes('new OrcaRuntimeRpcServer(')) + .map(({ filePath }) => filePath) + + expect(valueImporters).toEqual([capabilityPath]) + expect(constructors).toEqual([capabilityPath]) + }) + + it('loads at the former constructor point and immediately assigns the live server', () => { + const serveOptionsIndex = source.indexOf('let serveOptions: ServeOptions | null = null') + const serveResolutionIndex = source.indexOf( + 'serveOptions = isServeMode ? getServeOptions() : null', + serveOptionsIndex + ) + const pairingMigrationIndex = source.indexOf( + "migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData'))", + serveResolutionIndex + ) + const importIndex = source.indexOf(capabilityImport, pairingMigrationIndex) + const assignmentIndex = source.indexOf(capabilityFactory, importIndex) + const mobileHandlersIndex = source.indexOf( + 'registerMobileHandlers(runtimeRpc, {', + assignmentIndex + ) + + expect(serveResolutionIndex).toBeGreaterThan(serveOptionsIndex) + expect(pairingMigrationIndex).toBeGreaterThan(serveResolutionIndex) + expect(importIndex).toBeGreaterThan(pairingMigrationIndex) + expect(assignmentIndex).toBeGreaterThan(importIndex) + expect(mobileHandlersIndex).toBeGreaterThan(assignmentIndex) + }) + + it('preserves every constructor option expression in order', () => { + const assignmentIndex = source.indexOf(capabilityFactory) + const mobileHandlersIndex = source.indexOf( + 'registerMobileHandlers(runtimeRpc, {', + assignmentIndex + ) + const construction = source.slice(assignmentIndex, mobileHandlersIndex) + const identities = [ + 'runtime,', + 'userDataPath: getCanonicalUserDataPath(),', + 'enableWebSocket: true,', + '...(isE2E ? { wsPort: e2eWsPort } : {}),', + '...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),', + '...(serveOptions?.wsPort !== undefined', + 'wsPort: serveOptions.wsPort,', + 'preferPinnedWsPort: true', + 'webClientRoot: getBundledWebClientRoot()' + ] + let previousIndex = -1 + + for (const identity of identities) { + const identityIndex = construction.indexOf(identity, previousIndex + 1) + expect(identityIndex).toBeGreaterThan(previousIndex) + previousIndex = identityIndex + } + }) + + it('keeps every downstream lifecycle consumer on the assigned singleton', () => { + const assignmentIndex = source.indexOf(capabilityFactory) + const orderedConsumers = [ + 'registerMobileHandlers(runtimeRpc, {', + 'runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {', + 'startTerminalRuntimeStartupServices()', + "app.on('activate', handleMacAppActivation)", + 'if (serveOptions) {' + ] + let previousIndex = assignmentIndex + + for (const consumer of orderedConsumers) { + const consumerIndex = source.indexOf(consumer, previousIndex) + expect(consumerIndex).toBeGreaterThan(previousIndex) + previousIndex = consumerIndex + } + + const serveStartIndex = source.indexOf('await runtimeRpc.start()', previousIndex) + const desktopStartIndex = source.indexOf( + 'const [win, runtimeRpcStartResult] = await Promise', + serveStartIndex + ) + const desktopRpcIndex = source.indexOf('runtimeRpc.start().then(', desktopStartIndex) + const relayIndex = source.indexOf('runtimeRpc,', desktopRpcIndex) + const quitIndex = source.indexOf("app.on('will-quit'") + const stopIndex = source.indexOf('.stop()', quitIndex) + + expect(serveStartIndex).toBeGreaterThan(previousIndex) + expect(desktopStartIndex).toBeGreaterThan(serveStartIndex) + expect(desktopRpcIndex).toBeGreaterThan(desktopStartIndex) + expect(relayIndex).toBeGreaterThan(desktopRpcIndex) + expect(stopIndex).toBeGreaterThan(quitIndex) + }) +}) diff --git a/src/main/startup/runtime-rpc-server-startup-capability.test.ts b/src/main/startup/runtime-rpc-server-startup-capability.test.ts new file mode 100644 index 00000000000..3415f7e3256 --- /dev/null +++ b/src/main/startup/runtime-rpc-server-startup-capability.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serverMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../runtime/runtime-rpc', () => ({ + OrcaRuntimeRpcServer: class { + constructor(options: unknown) { + serverMocks.constructor(this, options) + } + } +})) + +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { createOrcaRuntimeRpcServerStartupCapability } from './runtime-rpc-server-startup-capability' + +describe('runtime RPC server startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs and returns the same live server with the original options by identity', () => { + const options = { + runtime: { getRuntimeId: vi.fn() }, + userDataPath: 'profile-data', + enableWebSocket: true, + wsPort: 6769, + preferPinnedWsPort: true, + webClientRoot: 'web-client' + } + + const server = createOrcaRuntimeRpcServerStartupCapability(options as never) + + expect(serverMocks.constructor).toHaveBeenCalledOnce() + expect(serverMocks.constructor).toHaveBeenCalledWith(server, options) + expect(server).toBeInstanceOf(OrcaRuntimeRpcServer) + }) +}) diff --git a/src/main/startup/runtime-rpc-server-startup-capability.ts b/src/main/startup/runtime-rpc-server-startup-capability.ts new file mode 100644 index 00000000000..fedd305ccda --- /dev/null +++ b/src/main/startup/runtime-rpc-server-startup-capability.ts @@ -0,0 +1,9 @@ +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' + +type OrcaRuntimeRpcServerParameters = ConstructorParameters + +export function createOrcaRuntimeRpcServerStartupCapability( + options: OrcaRuntimeRpcServerParameters[0] +): OrcaRuntimeRpcServer { + return new OrcaRuntimeRpcServer(options) +} diff --git a/src/main/startup/runtime-service-startup-boundary.test.ts b/src/main/startup/runtime-service-startup-boundary.test.ts new file mode 100644 index 00000000000..a71d7952bdf --- /dev/null +++ b/src/main/startup/runtime-service-startup-boundary.test.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const runtimeModule = './runtime/orca-runtime' +const capabilityModule = './startup/runtime-service-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = + 'const runtimeService = createOrcaRuntimeServiceStartupCapability(store, stats, {' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('runtime service startup boundary', () => { + it('keeps the implementation out of the eager main graph behind one factory', () => { + expect(findImport(runtimeModule)?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new OrcaRuntimeService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('constructs at the former point and assigns the same singleton immediately', () => { + const accountResolverIndex = source.indexOf('rateLimits.setInactiveCodexAccountsResolver(') + const transportIndex = source.indexOf( + 'const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {', + accountResolverIndex + ) + const importIndex = source.indexOf(capabilityImport, transportIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const globalAssignmentIndex = source.indexOf('runtime = runtimeService', factoryIndex) + const providerPublicationIndex = source.indexOf( + 'publishProviderSessionChanges(agentHooks.agentHookServer.getProviderSessionIdentities())', + globalAssignmentIndex + ) + const browserListenerIndex = source.indexOf( + 'browserManager.setBrowserGuestStateChangedListener(', + providerPublicationIndex + ) + const automationImportIndex = source.indexOf( + "await import('./startup/automation-service-startup-capability')", + browserListenerIndex + ) + + expect(transportIndex).toBeGreaterThan(accountResolverIndex) + expect(importIndex).toBeGreaterThan(transportIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(globalAssignmentIndex).toBeGreaterThan(factoryIndex) + expect(providerPublicationIndex).toBeGreaterThan(globalAssignmentIndex) + expect(browserListenerIndex).toBeGreaterThan(providerPublicationIndex) + expect(automationImportIndex).toBeGreaterThan(browserListenerIndex) + }) + + it('passes every original constructor input and callback by identity', () => { + const factoryIndex = source.indexOf(capabilityFactory) + const assignmentIndex = source.indexOf('runtime = runtimeService', factoryIndex) + const construction = source.slice(factoryIndex, assignmentIndex) + const identities = [ + 'store, stats, {', + 'agentSessionClaimSigner: runtimeConnectivity.loadAgentSessionClaimSigner(', + 'getLocalProvider: () => terminalRuntime.getLocalPtyProvider()', + 'getSshProvider: (connectionId) => terminalRuntime.getSshPtyProvider(connectionId)', + 'onPtyStopped: terminalRuntime.clearProviderPtyState', + 'onTerminalAgentStatus: (event) => {', + 'onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {', + 'getDesktopWindowStatus: getDesktopWindowStatus', + 'getAgentStatusSnapshot: () =>', + 'getAgentProviderSessionSnapshot: () => agentHooks.agentHookServer.getStatusSnapshot()', + 'getAgentProviderSessionRowsForPane: (paneKey) =>', + 'getAdditionalAiVaultCodexHomePaths: () =>', + 'prepareAiVaultSessionResume: (args) =>', + 'buildAgentHookPtyEnv: () =>', + 'orchestrationEnvironmentTransport' + ] + + for (const identity of identities) { + expect(construction).toContain(identity) + } + }) + + it('preserves downstream wiring, startup branches, and teardown on one instance', () => { + const assignmentIndex = source.indexOf('runtime = runtimeService') + const orderedConsumers = [ + 'runtimeService.setAutomationService(automations)', + 'runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })', + 'runtimeService.setCommitMessageAgentEnvironmentResolvers({', + 'runtimeService.onWorktreeLifecycle((event) => {', + 'attachAgentBrowserStartupCapability(runtimeService, browserManager)', + 'attachEmulatorStartupCapability(runtimeService)', + 'runtimeRpc = createOrcaRuntimeRpcServerStartupCapability({', + 'registerMobileHandlers(runtimeRpc, {' + ] + let previousIndex = assignmentIndex + + for (const consumer of orderedConsumers) { + const consumerIndex = source.indexOf(consumer, previousIndex) + expect(consumerIndex).toBeGreaterThan(previousIndex) + previousIndex = consumerIndex + } + + const serveIndex = source.indexOf('if (serveOptions) {', previousIndex) + const headlessPtyIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + const desktopLoadIndex = source.indexOf( + 'await loadCoreIpcRegistryForDesktop()', + headlessPtyIndex + ) + const coreHandlersIndex = source.indexOf('registerCoreHandlers(') + const quitIndex = source.indexOf("app.on('will-quit'") + const browserTeardownIndex = source.indexOf( + 'runtime?.getAgentBrowserBridge()?.destroyAllSessions()', + quitIndex + ) + const offscreenTeardownIndex = source.indexOf( + 'runtime?.getOffscreenBrowserBackend()?.destroyAll?.()', + browserTeardownIndex + ) + const emulatorTeardownIndex = source.indexOf( + 'runtime?.getEmulatorBridge()?.destroyAllSessions()', + offscreenTeardownIndex + ) + + expect(headlessPtyIndex).toBeGreaterThan(serveIndex) + expect(desktopLoadIndex).toBeGreaterThan(headlessPtyIndex) + expect(coreHandlersIndex).toBeGreaterThanOrEqual(0) + expect(browserTeardownIndex).toBeGreaterThan(quitIndex) + expect(offscreenTeardownIndex).toBeGreaterThan(browserTeardownIndex) + expect(emulatorTeardownIndex).toBeGreaterThan(offscreenTeardownIndex) + }) +}) diff --git a/src/main/startup/runtime-service-startup-capability.test.ts b/src/main/startup/runtime-service-startup-capability.test.ts new file mode 100644 index 00000000000..1029390464e --- /dev/null +++ b/src/main/startup/runtime-service-startup-capability.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const runtimeMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../runtime/orca-runtime', () => ({ + OrcaRuntimeService: class { + constructor(store: unknown, stats: unknown, dependencies: unknown) { + runtimeMocks.constructor(this, store, stats, dependencies) + } + } +})) + +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { createOrcaRuntimeServiceStartupCapability } from './runtime-service-startup-capability' + +describe('runtime service startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs and returns the same live instance with every input by identity', () => { + const store = { getMobileClientTabSelections: vi.fn() } + const stats = { recordAgentSession: vi.fn() } + const dependencies = { + agentSessionClaimSigner: { sign: vi.fn() }, + getLocalProvider: vi.fn(), + getSshProvider: vi.fn(), + onPtyStopped: vi.fn(), + onTerminalAgentStatus: vi.fn(), + onTerminalSideEffects: vi.fn(), + getDesktopWindowStatus: vi.fn(), + getAgentStatusSnapshot: vi.fn(), + getAgentProviderSessionSnapshot: vi.fn(), + getAgentProviderSessionRowsForPane: vi.fn(), + getAdditionalAiVaultCodexHomePaths: vi.fn(), + prepareAiVaultSessionResume: vi.fn(), + buildAgentHookPtyEnv: vi.fn(), + orchestrationEnvironmentTransport: { resolve: vi.fn(), call: vi.fn() } + } + + const runtime = createOrcaRuntimeServiceStartupCapability( + store as never, + stats as never, + dependencies as never + ) + + expect(runtimeMocks.constructor).toHaveBeenCalledOnce() + expect(runtimeMocks.constructor).toHaveBeenCalledWith(runtime, store, stats, dependencies) + expect(runtime).toBeInstanceOf(OrcaRuntimeService) + }) +}) diff --git a/src/main/startup/runtime-service-startup-capability.ts b/src/main/startup/runtime-service-startup-capability.ts new file mode 100644 index 00000000000..b0d1d480d82 --- /dev/null +++ b/src/main/startup/runtime-service-startup-capability.ts @@ -0,0 +1,9 @@ +import { OrcaRuntimeService } from '../runtime/orca-runtime' + +type OrcaRuntimeServiceParameters = ConstructorParameters + +export function createOrcaRuntimeServiceStartupCapability( + ...args: OrcaRuntimeServiceParameters +): OrcaRuntimeService { + return new OrcaRuntimeService(...args) +} diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index d1cb5bfd2fb..0bb54522c27 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -49,6 +49,8 @@ describe('serve desktop activation wiring', () => { }) it('keeps the headless install policy after desktop promotion', () => { - expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)') + expect(source).toContain( + 'updateInstallMode: getUpdaterRuntimeStartupCapability().resolveUpdateInstallMode(isServeMode)' + ) }) }) diff --git a/src/main/startup/shutdown-watcher-runtime-metadata-startup-boundary.test.ts b/src/main/startup/shutdown-watcher-runtime-metadata-startup-boundary.test.ts new file mode 100644 index 00000000000..9682e0559ca --- /dev/null +++ b/src/main/startup/shutdown-watcher-runtime-metadata-startup-boundary.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/shutdown-watcher-runtime-metadata-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('shutdown watcher/runtime-metadata startup boundary', () => { + it('moves only the five shutdown leaves behind one owner', () => { + for (const moduleSpecifier of [ + './ipc/filesystem-watcher', + './ipc/worktree-base-directory-watcher', + './runtime/orca-runtime-files', + './runtime/runtime-metadata', + './quit-teardown-deadline' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./startup/shutdown-watcher-runtime-metadata-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after runtime connectivity and before resource creation', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const connectivityInstallIndex = source.indexOf( + 'installRuntimeConnectivityStartupCapability(runtimeConnectivity)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, connectivityInstallIndex) + const factoryIndex = source.indexOf( + 'const shutdownWatcherRuntimeMetadata = createShutdownWatcherRuntimeMetadataStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installShutdownWatcherRuntimeMetadataStartupCapability(shutdownWatcherRuntimeMetadata)', + factoryIndex + ) + const runtimeRpcIndex = source.indexOf( + 'runtimeRpc = createOrcaRuntimeRpcServerStartupCapability({', + installIndex + ) + const coreIpcIndex = source.indexOf('await loadCoreIpcRegistryForDesktop()', installIndex) + const windowIndex = source.indexOf('Promise.resolve(openMainWindow())', coreIpcIndex) + + expect(importIndex).toBeGreaterThan(connectivityInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(runtimeRpcIndex).toBeGreaterThan(installIndex) + expect(coreIpcIndex).toBeGreaterThan(installIndex) + expect(windowIndex).toBeGreaterThan(coreIpcIndex) + }) + + it('makes only resource-free early quit optional without memoizing it', () => { + const shutdownStart = source.indexOf('function shutdownWatchersOnce(): Promise {') + const shutdownEnd = source.indexOf('\n// Why: cursor-agent', shutdownStart) + const shutdownBlock = source.slice(shutdownStart, shutdownEnd) + const optionalIndex = shutdownBlock.indexOf( + 'getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled()' + ) + const earlyReturnIndex = shutdownBlock.indexOf('return Promise.resolve()', optionalIndex) + const memoIndex = shutdownBlock.indexOf('if (!watcherShutdownPromise)', earlyReturnIndex) + + expect(optionalIndex).toBeGreaterThanOrEqual(0) + expect(earlyReturnIndex).toBeGreaterThan(optionalIndex) + expect(memoIndex).toBeGreaterThan(earlyReturnIndex) + expect(shutdownBlock).toContain('shutdown.closeAllWatchers()') + expect(shutdownBlock).toContain('shutdown.disposeWorktreeBaseDirectoryWatchers()') + }) + + it('preserves RPC stop, watcher drain, metadata clear, and teardown ordering', () => { + const quitIndex = source.indexOf("app.on('will-quit', (e) => {") + const quitBlock = source.slice(quitIndex) + const statsIndex = quitBlock.indexOf('stats?.flush()') + const ptyIndex = quitBlock.indexOf('terminalRuntime?.killAllPty()', statsIndex) + const watcherIndex = quitBlock.indexOf( + 'const watcherShutdown = shutdownWatchersOnce()', + ptyIndex + ) + const rpcStopIndex = quitBlock.indexOf('.stop()', watcherIndex) + const drainIndex = quitBlock.indexOf('.awaitRuntimeFileWatcherUnsubscribes()', rpcStopIndex) + const clearIndex = quitBlock.indexOf('.clearRuntimeMetadataIfOwned(', drainIndex) + const deadlineIndex = quitBlock.indexOf( + 'shutdownWatcherRuntimeMetadata.settleTeardownWithinDeadline([', + clearIndex + ) + + expect(ptyIndex).toBeGreaterThan(statsIndex) + expect(watcherIndex).toBeGreaterThan(ptyIndex) + expect(rpcStopIndex).toBeGreaterThan(watcherIndex) + expect(drainIndex).toBeGreaterThan(rpcStopIndex) + expect(clearIndex).toBeGreaterThan(drainIndex) + expect(deadlineIndex).toBeGreaterThan(clearIndex) + expect(quitBlock).toContain( + 'getCanonicalUserDataPath(),\n ownedPid,\n ownedRuntimeId' + ) + expect(quitBlock).toContain("{ name: 'watchers', promise: watcherShutdown }") + expect(quitBlock).toContain(': Promise.resolve([])') + }) + + it('keeps bootstrap, platform, runtime, mobile, relay, i18n, and shutdown owners intact', () => { + for (const moduleSpecifier of [ + './startup/configure-process', + './startup/gpu-fallback-marker', + '../shared/cross-platform-path', + './wsl', + './agent-auth-restart-preservation', + './i18n/main-i18n' + ]) { + expect(findImport(moduleSpecifier)).toBeDefined() + } + + expect(source).toContain("app.on('before-quit', () => {") + expect(source).toContain("app.on('will-quit', (e) => {") + expect(source).toContain('const ownedPid = process.pid') + expect(source).toContain('const ownedRuntimeId = runtime?.getRuntimeId()') + }) +}) diff --git a/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.test.ts b/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.test.ts new file mode 100644 index 00000000000..fb17f8ac828 --- /dev/null +++ b/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +const shutdownMocks = vi.hoisted(() => ({ + awaitRuntimeFileWatcherUnsubscribes: vi.fn(), + clearRuntimeMetadataIfOwned: vi.fn(), + closeAllWatchers: vi.fn(), + disposeWorktreeBaseDirectoryWatchers: vi.fn(), + settleTeardownWithinDeadline: vi.fn() +})) + +vi.mock('../ipc/filesystem-watcher', () => ({ + closeAllWatchers: shutdownMocks.closeAllWatchers +})) +vi.mock('../ipc/worktree-base-directory-watcher', () => ({ + disposeWorktreeBaseDirectoryWatchers: shutdownMocks.disposeWorktreeBaseDirectoryWatchers +})) +vi.mock('../quit-teardown-deadline', () => ({ + settleTeardownWithinDeadline: shutdownMocks.settleTeardownWithinDeadline +})) +vi.mock('../runtime/orca-runtime-files', () => ({ + awaitRuntimeFileWatcherUnsubscribes: shutdownMocks.awaitRuntimeFileWatcherUnsubscribes +})) +vi.mock('../runtime/runtime-metadata', () => ({ + clearRuntimeMetadataIfOwned: shutdownMocks.clearRuntimeMetadataIfOwned +})) + +import { createShutdownWatcherRuntimeMetadataStartupCapability } from './shutdown-watcher-runtime-metadata-startup-capability' + +describe('shutdown watcher/runtime-metadata startup capability', () => { + it('returns every original function identity', () => { + expect(createShutdownWatcherRuntimeMetadataStartupCapability()).toEqual(shutdownMocks) + }) +}) diff --git a/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.ts b/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.ts new file mode 100644 index 00000000000..fbfdb168a80 --- /dev/null +++ b/src/main/startup/shutdown-watcher-runtime-metadata-startup-capability.ts @@ -0,0 +1,23 @@ +import { closeAllWatchers } from '../ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' +import { settleTeardownWithinDeadline } from '../quit-teardown-deadline' +import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/orca-runtime-files' +import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' + +export type ShutdownWatcherRuntimeMetadataStartupCapability = { + awaitRuntimeFileWatcherUnsubscribes: typeof awaitRuntimeFileWatcherUnsubscribes + clearRuntimeMetadataIfOwned: typeof clearRuntimeMetadataIfOwned + closeAllWatchers: typeof closeAllWatchers + disposeWorktreeBaseDirectoryWatchers: typeof disposeWorktreeBaseDirectoryWatchers + settleTeardownWithinDeadline: typeof settleTeardownWithinDeadline +} + +export function createShutdownWatcherRuntimeMetadataStartupCapability(): ShutdownWatcherRuntimeMetadataStartupCapability { + return { + awaitRuntimeFileWatcherUnsubscribes, + clearRuntimeMetadataIfOwned, + closeAllWatchers, + disposeWorktreeBaseDirectoryWatchers, + settleTeardownWithinDeadline + } +} diff --git a/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.test.ts b/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.test.ts new file mode 100644 index 00000000000..b8aed60553b --- /dev/null +++ b/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('shutdown watcher/runtime-metadata startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation', async () => { + const { getShutdownWatcherRuntimeMetadataStartupCapability } = + await import('./shutdown-watcher-runtime-metadata-startup-owner') + + expect(() => getShutdownWatcherRuntimeMetadataStartupCapability()).toThrow( + 'Shutdown watcher/runtime-metadata capability must be initialized before use' + ) + }) + + it('offers an explicit pre-install no-op path', async () => { + const { getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled } = + await import('./shutdown-watcher-runtime-metadata-startup-owner') + + expect(getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled()).toBeNull() + }) + + it('returns the exact installed capability on both paths', async () => { + const { + getShutdownWatcherRuntimeMetadataStartupCapability, + getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled, + installShutdownWatcherRuntimeMetadataStartupCapability + } = await import('./shutdown-watcher-runtime-metadata-startup-owner') + const capability = { + awaitRuntimeFileWatcherUnsubscribes: vi.fn(), + clearRuntimeMetadataIfOwned: vi.fn(), + closeAllWatchers: vi.fn(), + disposeWorktreeBaseDirectoryWatchers: vi.fn(), + settleTeardownWithinDeadline: vi.fn() + } + + installShutdownWatcherRuntimeMetadataStartupCapability(capability as never) + + expect(getShutdownWatcherRuntimeMetadataStartupCapability()).toBe(capability) + expect(getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.ts b/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.ts new file mode 100644 index 00000000000..b1da02e4601 --- /dev/null +++ b/src/main/startup/shutdown-watcher-runtime-metadata-startup-owner.ts @@ -0,0 +1,20 @@ +import type { ShutdownWatcherRuntimeMetadataStartupCapability } from './shutdown-watcher-runtime-metadata-startup-capability' + +let capability: ShutdownWatcherRuntimeMetadataStartupCapability | null = null + +export function installShutdownWatcherRuntimeMetadataStartupCapability( + nextCapability: ShutdownWatcherRuntimeMetadataStartupCapability +): void { + capability = nextCapability +} + +export function getShutdownWatcherRuntimeMetadataStartupCapability(): ShutdownWatcherRuntimeMetadataStartupCapability { + if (!capability) { + throw new Error('Shutdown watcher/runtime-metadata capability must be initialized before use') + } + return capability +} + +export function getShutdownWatcherRuntimeMetadataStartupCapabilityIfInstalled(): ShutdownWatcherRuntimeMetadataStartupCapability | null { + return capability +} diff --git a/src/main/startup/star-nag-startup-boundary.test.ts b/src/main/startup/star-nag-startup-boundary.test.ts new file mode 100644 index 00000000000..b92ec008c4c --- /dev/null +++ b/src/main/startup/star-nag-startup-boundary.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/star-nag-startup-capability')" +const capabilityFactory = 'starNag = await createStarNagStartupCapability(store, stats)' + +describe('star nag startup boundary', () => { + it('keeps the service implementation out of the eager main graph', () => { + const serviceImport = sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => + (declaration.moduleSpecifier as ts.StringLiteral).text === './star-nag/service' + ) + + expect(serviceImport?.importClause?.isTypeOnly).toBe(true) + expect(source).not.toContain('new StarNagService(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('starts and registers the service before later attachments and readiness', () => { + const importIndex = source.indexOf(capabilityImport) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const agentBrowserIndex = source.indexOf( + "await import('./startup/agent-browser-startup-capability')", + factoryIndex + ) + const emulatorIndex = source.indexOf( + "await import('./startup/emulator-startup-capability')", + agentBrowserIndex + ) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(importIndex).toBeGreaterThanOrEqual(0) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(agentBrowserIndex).toBeGreaterThan(factoryIndex) + expect(emulatorIndex).toBeGreaterThan(agentBrowserIndex) + expect(initializedIndex).toBeGreaterThan(emulatorIndex) + }) + + it('preserves the committed will-quit stop contract', () => { + const willQuitIndex = source.indexOf("app.on('will-quit'") + const stopIndex = source.indexOf('starNag?.stop()', willQuitIndex) + + expect(willQuitIndex).toBeGreaterThanOrEqual(0) + expect(stopIndex).toBeGreaterThan(willQuitIndex) + expect(source.split('starNag?.stop()')).toHaveLength(2) + }) +}) diff --git a/src/main/startup/star-nag-startup-capability.test.ts b/src/main/startup/star-nag-startup-capability.test.ts new file mode 100644 index 00000000000..5d778b40851 --- /dev/null +++ b/src/main/startup/star-nag-startup-capability.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const serviceMocks = vi.hoisted(() => ({ + constructor: vi.fn(), + registerIpcHandlers: vi.fn(), + start: vi.fn() +})) + +vi.mock('../star-nag/service', () => ({ + StarNagService: class { + constructor(store: unknown, stats: unknown) { + serviceMocks.constructor(store, stats) + } + + start(): void { + serviceMocks.start() + } + + registerIpcHandlers(): void { + serviceMocks.registerIpcHandlers() + } + } +})) + +import { StarNagService } from '../star-nag/service' +import { createStarNagStartupCapability } from './star-nag-startup-capability' + +describe('star nag startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the started live service after registering IPC handlers', async () => { + const store = {} + const stats = {} + + const service = await createStarNagStartupCapability(store as never, stats as never) + + expect(serviceMocks.constructor).toHaveBeenCalledOnce() + expect(serviceMocks.constructor).toHaveBeenCalledWith(store, stats) + expect(serviceMocks.start).toHaveBeenCalledOnce() + expect(serviceMocks.registerIpcHandlers).toHaveBeenCalledOnce() + expect(serviceMocks.start.mock.invocationCallOrder[0]).toBeLessThan( + serviceMocks.registerIpcHandlers.mock.invocationCallOrder[0] + ) + expect(service).toBeInstanceOf(StarNagService) + }) +}) diff --git a/src/main/startup/star-nag-startup-capability.ts b/src/main/startup/star-nag-startup-capability.ts new file mode 100644 index 00000000000..4c0c8708fa5 --- /dev/null +++ b/src/main/startup/star-nag-startup-capability.ts @@ -0,0 +1,13 @@ +import type { Store } from '../persistence' +import { StarNagService } from '../star-nag/service' +import type { StatsCollector } from '../stats/collector' + +export async function createStarNagStartupCapability( + store: Store, + stats: StatsCollector +): Promise { + const service = new StarNagService(store, stats) + service.start() + service.registerIpcHandlers() + return service +} diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts index d5cd7718d88..04950ea655f 100644 --- a/src/main/startup/startup-diagnostics.ts +++ b/src/main/startup/startup-diagnostics.ts @@ -1,6 +1,7 @@ import { writeSync } from 'node:fs' export const STARTUP_DIAGNOSTICS_ENV = 'ORCA_STARTUP_DIAGNOSTICS' +export const STARTUP_PROCESS_CLOCK = 'process-performance-now-ms' export type StartupDiagnosticSink = (fd: number, text: string) => unknown @@ -30,10 +31,12 @@ export function logStartupDiagnostic( writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write) } -// Why: startup benchmarking needs in-process timestamps — harness-side stderr -// arrival times include pipe buffering jitter. `t` is ms since process start. export function logStartupMilestone(event: string, details: Record = {}): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + logStartupDiagnostic(event, { + ...details, + t: Math.round(performance.now() * 10) / 10, + clock: STARTUP_PROCESS_CLOCK + }) } } diff --git a/src/main/startup/stats-collector-startup-boundary.test.ts b/src/main/startup/stats-collector-startup-boundary.test.ts new file mode 100644 index 00000000000..05e2df3f767 --- /dev/null +++ b/src/main/startup/stats-collector-startup-boundary.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/stats-collector-startup-capability')" +const capabilityFactory = 'stats = await createStatsCollectorStartupCapability()' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('StatsCollector startup boundary', () => { + it('keeps only the path capture in the eager main graph', () => { + expect(findImport('./stats/collector')?.importClause?.isTypeOnly).toBe(true) + expect(findImport('./stats/stats-file-path')?.importClause?.isTypeOnly).toBe(false) + expect(source).not.toContain('new StatsCollector(') + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + }) + + it('captures the path before app naming and constructs at the original services point', () => { + const dataPathIndex = source.indexOf('initDataPath()') + const statsPathIndex = source.indexOf('initStatsPath()', dataPathIndex) + const whenReadyIndex = source.indexOf('void app.whenReady()') + const appNameIndex = source.indexOf('app.setName(', whenReadyIndex) + const cohortIndex = source.indexOf('initOnboardingCohortClassifier(store)') + const importIndex = source.indexOf(capabilityImport, cohortIndex) + const factoryIndex = source.indexOf(capabilityFactory, importIndex) + const usageIndex = source.indexOf('claudeUsage = new ClaudeUsageStore(store)', factoryIndex) + const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')") + + expect(dataPathIndex).toBeGreaterThanOrEqual(0) + expect(statsPathIndex).toBeGreaterThan(dataPathIndex) + expect(whenReadyIndex).toBeGreaterThan(statsPathIndex) + expect(appNameIndex).toBeGreaterThan(whenReadyIndex) + expect(importIndex).toBeGreaterThan(cohortIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(usageIndex).toBeGreaterThan(factoryIndex) + expect(initializedIndex).toBeGreaterThan(usageIndex) + }) + + it('preserves singleton consumers and the committed flush lifecycle', () => { + const runtimeIndex = source.indexOf('new OrcaRuntimeService(store, stats, {') + const starNagIndex = source.indexOf('createStarNagStartupCapability(store, stats)') + const handlersIndex = source.indexOf('registerCoreHandlers(') + const willQuitIndex = source.indexOf("app.on('will-quit'") + const flushIndex = source.indexOf('stats?.flush()', willQuitIndex) + const killIndex = source.indexOf('killAllPty()', flushIndex) + + expect(runtimeIndex).toBeGreaterThanOrEqual(0) + expect(starNagIndex).toBeGreaterThan(runtimeIndex) + expect(handlersIndex).toBeGreaterThanOrEqual(0) + expect(flushIndex).toBeGreaterThan(willQuitIndex) + expect(killIndex).toBeGreaterThan(flushIndex) + expect(source.split('stats?.flush()')).toHaveLength(2) + }) +}) diff --git a/src/main/startup/stats-collector-startup-capability.test.ts b/src/main/startup/stats-collector-startup-capability.test.ts new file mode 100644 index 00000000000..9b8322aaa38 --- /dev/null +++ b/src/main/startup/stats-collector-startup-capability.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const collectorMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../stats/collector', () => ({ + StatsCollector: class { + constructor() { + collectorMocks.constructor() + } + } +})) + +import { StatsCollector } from '../stats/collector' +import { createStatsCollectorStartupCapability } from './stats-collector-startup-capability' + +describe('StatsCollector startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the constructed live collector', async () => { + const collector = await createStatsCollectorStartupCapability() + + expect(collectorMocks.constructor).toHaveBeenCalledOnce() + expect(collector).toBeInstanceOf(StatsCollector) + }) +}) diff --git a/src/main/startup/stats-collector-startup-capability.ts b/src/main/startup/stats-collector-startup-capability.ts new file mode 100644 index 00000000000..c666bbf4a23 --- /dev/null +++ b/src/main/startup/stats-collector-startup-capability.ts @@ -0,0 +1,5 @@ +import { StatsCollector } from '../stats/collector' + +export function createStatsCollectorStartupCapability(): StatsCollector { + return new StatsCollector() +} diff --git a/src/main/startup/store-startup-boundary.test.ts b/src/main/startup/store-startup-boundary.test.ts new file mode 100644 index 00000000000..fe14a2d5499 --- /dev/null +++ b/src/main/startup/store-startup-boundary.test.ts @@ -0,0 +1,138 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const projectRoot = process.cwd() +const mainRoot = join(projectRoot, 'src/main') +const indexSource = readFileSync(join(mainRoot, 'index.ts'), 'utf8') +const indexSourceFile = ts.createSourceFile('index.ts', indexSource, ts.ScriptTarget.Latest, false) +const capabilityImport = "await import('./startup/store-startup-capability')" +const capabilityFactory = + 'store = createStoreStartupCapability({ dataFile: activeOrcaProfile.dataFile })' + +function listProductionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + return listProductionTypeScriptFiles(entryPath) + } + return entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.spec.ts') + ? [entryPath] + : [] + }) +} + +function findIndexImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return indexSourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('Store startup boundary', () => { + it('keeps Store type-only in index behind one startup capability', () => { + expect(findIndexImport('./persistence')?.importClause?.isTypeOnly).toBe(true) + expect(findIndexImport('./persistence-data-path')?.importClause?.isTypeOnly).toBe(false) + expect(indexSource).not.toContain('new Store(') + expect(indexSource.split(capabilityImport)).toHaveLength(2) + expect(indexSource.split(capabilityFactory)).toHaveLength(2) + }) + + it('keeps the sole production Store value import and constructor in the capability', () => { + const capabilityPath = join(mainRoot, 'startup/store-startup-capability.ts') + const productionSources = listProductionTypeScriptFiles(mainRoot).map((filePath) => ({ + filePath, + source: readFileSync(filePath, 'utf8') + })) + const valueImporters = productionSources + .filter(({ source }) => source.includes("import { Store } from '../persistence'")) + .map(({ filePath }) => filePath) + const constructors = productionSources + .filter(({ source }) => source.includes('new Store(...args)')) + .map(({ filePath }) => filePath) + + expect(valueImporters).toEqual([capabilityPath]) + expect(constructors).toEqual([capabilityPath]) + }) + + it('routes production path consumers through the narrow shared module', () => { + const productionSources = listProductionTypeScriptFiles(mainRoot).map((filePath) => ({ + filePath, + source: readFileSync(filePath, 'utf8') + })) + const stalePathImports = productionSources.filter( + ({ source }) => + source.includes("getCanonicalUserDataPath } from '../persistence'") || + source.includes("getCanonicalUserDataPath } from './persistence'") + ) + + expect(stalePathImports).toEqual([]) + expect(indexSource).toContain("} from './persistence-data-path'") + expect(readFileSync(join(mainRoot, 'ipc/cli.ts'), 'utf8')).toContain( + "from '../persistence-data-path'" + ) + expect(readFileSync(join(mainRoot, 'macos-tcc-prompt-notice.ts'), 'utf8')).toContain( + "from './persistence-data-path'" + ) + expect(readFileSync(join(mainRoot, 'serve-update-handoff.ts'), 'utf8')).toContain( + "from './persistence-data-path'" + ) + expect( + readFileSync(join(mainRoot, 'ssh/ssh-remote-cli-host-passthrough.ts'), 'utf8') + ).toContain("from '../persistence-data-path'") + }) + + it('preserves early path capture and exact Store construction order', () => { + const initIndex = indexSource.indexOf('initDataPath()') + const appNameIndex = indexSource.indexOf('app.setName(devInstanceIdentity.appName)') + const profileIndex = indexSource.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const importIndex = indexSource.indexOf(capabilityImport, profileIndex) + const factoryIndex = indexSource.indexOf(capabilityFactory, importIndex) + const milestoneIndex = indexSource.indexOf("logStartupMilestone('store-loaded')", factoryIndex) + + expect(initIndex).toBeGreaterThanOrEqual(0) + expect(appNameIndex).toBeGreaterThan(initIndex) + expect(profileIndex).toBeGreaterThan(appNameIndex) + expect(importIndex).toBeGreaterThan(profileIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(milestoneIndex).toBeGreaterThan(factoryIndex) + }) + + it('keeps hydration, services, startup branches, and teardown after assignment', () => { + const factoryIndex = indexSource.indexOf(capabilityFactory) + const orderedConsumers = [ + 'setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)', + 'store.onSettingsChanged((updates, settings) => {', + 'attachClaudeLivePtyPersistence(store)', + 'applyAppIcon(store.getSettings().appIcon)', + 'initTelemetry(store)', + 'initCohortClassifier(store)', + 'createClaudeUsageStoreStartupCapability(store)', + 'createAccountServicesStartupCapability(store, {', + 'createOrcaRuntimeServiceStartupCapability(store, stats, {', + 'registerMobileHandlers(runtimeRpc, {', + 'if (serveOptions) {' + ] + let previousIndex = factoryIndex + + for (const consumer of orderedConsumers) { + const consumerIndex = indexSource.indexOf(consumer, previousIndex) + expect(consumerIndex).toBeGreaterThan(previousIndex) + previousIndex = consumerIndex + } + + const desktopIndex = indexSource.indexOf( + 'const [win, runtimeRpcStartResult] = await Promise', + previousIndex + ) + const quitIndex = indexSource.indexOf("app.on('will-quit'") + const flushIndex = indexSource.indexOf('store?.flush()', quitIndex) + + expect(desktopIndex).toBeGreaterThan(previousIndex) + expect(flushIndex).toBeGreaterThan(quitIndex) + }) +}) diff --git a/src/main/startup/store-startup-capability.test.ts b/src/main/startup/store-startup-capability.test.ts new file mode 100644 index 00000000000..858df5d59ce --- /dev/null +++ b/src/main/startup/store-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const storeMocks = vi.hoisted(() => ({ + constructor: vi.fn() +})) + +vi.mock('../persistence', () => ({ + Store: class { + constructor(options: unknown) { + storeMocks.constructor(this, options) + } + } +})) + +import { Store } from '../persistence' +import { createStoreStartupCapability } from './store-startup-capability' + +describe('Store startup capability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs and returns the same live Store with the options object by identity', () => { + const options = { dataFile: 'profile-data.json' } + + const store = createStoreStartupCapability(options) + + expect(storeMocks.constructor).toHaveBeenCalledOnce() + expect(storeMocks.constructor).toHaveBeenCalledWith(store, options) + expect(store).toBeInstanceOf(Store) + }) +}) diff --git a/src/main/startup/store-startup-capability.ts b/src/main/startup/store-startup-capability.ts new file mode 100644 index 00000000000..6f420044827 --- /dev/null +++ b/src/main/startup/store-startup-capability.ts @@ -0,0 +1,7 @@ +import { Store } from '../persistence' + +type StoreParameters = ConstructorParameters + +export function createStoreStartupCapability(...args: StoreParameters): Store { + return new Store(...args) +} diff --git a/src/main/startup/telemetry-observability-startup-boundary.test.ts b/src/main/startup/telemetry-observability-startup-boundary.test.ts new file mode 100644 index 00000000000..757e487bfb1 --- /dev/null +++ b/src/main/startup/telemetry-observability-startup-boundary.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/telemetry-observability-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('telemetry/observability startup boundary', () => { + it('removes every targeted eager index value import behind one owner', () => { + for (const moduleSpecifier of [ + './observability', + './telemetry/client', + './telemetry/classify-error', + './telemetry/cohort-classifier', + './telemetry/onboarding-cohort-classifier', + './telemetry/consent' + ]) { + expect(findImport(moduleSpecifier)).toBeUndefined() + } + + expect(findImport('./startup/telemetry-observability-startup-owner')).toBeDefined() + expect(findImport(capabilityModule)).toBeUndefined() + expect(source.split(capabilityImport)).toHaveLength(2) + }) + + it('installs after Codex and before Store or any live telemetry consumer', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const codexInstallIndex = source.indexOf( + 'installCodexLaunchSessionStartupCapability(codexLaunchSession)', + readyIndex + ) + const importIndex = source.indexOf(capabilityImport, codexInstallIndex) + const factoryIndex = source.indexOf( + 'const telemetryObservability = createTelemetryObservabilityStartupCapability()', + importIndex + ) + const installIndex = source.indexOf( + 'installTelemetryObservabilityStartupCapability(telemetryObservability)', + factoryIndex + ) + const storeIndex = source.indexOf('store = createStoreStartupCapability(', installIndex) + const initIndex = source.indexOf('telemetryObservability.initTelemetry(store)', storeIndex) + + expect(importIndex).toBeGreaterThan(codexInstallIndex) + expect(factoryIndex).toBeGreaterThan(importIndex) + expect(installIndex).toBeGreaterThan(factoryIndex) + expect(storeIndex).toBeGreaterThan(installIndex) + expect(initIndex).toBeGreaterThan(storeIndex) + }) + + it('keeps predeclared daemon and window callbacks fail closed', () => { + const terminalStart = source.indexOf('function startTerminalRuntimeStartupServices():') + const terminalEnd = source.indexOf('\nfunction prepareCodexRuntimeHomeForLaunch', terminalStart) + const windowStart = source.indexOf('function openMainWindow(): BrowserWindow') + const windowEnd = source.indexOf('\nfunction sendOpenFeatureTour', windowStart) + + expect(source.slice(terminalStart, terminalEnd)).toContain( + 'getTelemetryObservabilityStartupCapability()' + ) + expect(source.slice(terminalStart, terminalEnd)).toContain( + "track('daemon_start_failed', classifyError(error))" + ) + expect(source.slice(windowStart, windowEnd)).toContain( + 'getTelemetryObservabilityStartupCapability()' + ) + expect(source.slice(windowStart, windowEnd)).toContain( + 'const consent = resolveConsent(store.getSettings())' + ) + expect(source.slice(windowStart, windowEnd)).toContain('trackAppOpenedOnce()') + }) + + it('preserves initialization, injection, cohort, and observability ordering', () => { + const telemetryIndex = source.indexOf('telemetryObservability.initTelemetry(store)') + const hangIndex = source.indexOf( + "telemetryObservability.track('main_thread_hang_detected'", + telemetryIndex + ) + const trustInjectionIndex = source.indexOf( + 'codexLaunchSession.setCodexTrustGrantTelemetry(', + hangIndex + ) + const trustTrackIndex = source.indexOf( + "telemetryObservability.track('codex_trust_grant'", + trustInjectionIndex + ) + const observabilityIndex = source.indexOf( + 'telemetryObservability.initObservability()', + trustTrackIndex + ) + const breadcrumbIndex = source.indexOf( + "recordDurableCrashBreadcrumb('main_process_lifecycle_started'", + observabilityIndex + ) + const cohortIndex = source.indexOf( + 'telemetryObservability.initCohortClassifier(store)', + breadcrumbIndex + ) + const onboardingIndex = source.indexOf( + 'telemetryObservability.initOnboardingCohortClassifier(store)', + cohortIndex + ) + + expect(hangIndex).toBeGreaterThan(telemetryIndex) + expect(trustInjectionIndex).toBeGreaterThan(hangIndex) + expect(trustTrackIndex).toBeGreaterThan(trustInjectionIndex) + expect(observabilityIndex).toBeGreaterThan(trustTrackIndex) + expect(breadcrumbIndex).toBeGreaterThan(observabilityIndex) + expect(cohortIndex).toBeGreaterThan(breadcrumbIndex) + expect(onboardingIndex).toBeGreaterThan(cohortIndex) + }) + + it('uses the optional owner only for pre-ready-safe shutdown in original order', () => { + const quitIndex = source.indexOf("app.on('will-quit', (e) => {") + const quitBlock = source.slice(quitIndex) + const optionalIndex = quitBlock.indexOf( + 'getTelemetryObservabilityStartupCapabilityIfInstalled()' + ) + const telemetryShutdownIndex = quitBlock.indexOf('telemetryObservability?.shutdownTelemetry()') + const observabilityShutdownIndex = quitBlock.indexOf( + 'telemetryObservability?.shutdownObservability()' + ) + const quitAgainIndex = quitBlock.indexOf('app.quit()', observabilityShutdownIndex) + + expect(optionalIndex).toBeGreaterThanOrEqual(0) + expect(telemetryShutdownIndex).toBeGreaterThan(optionalIndex) + expect(observabilityShutdownIndex).toBeGreaterThan(telemetryShutdownIndex) + expect(quitAgainIndex).toBeGreaterThan(observabilityShutdownIndex) + }) +}) diff --git a/src/main/startup/telemetry-observability-startup-capability.test.ts b/src/main/startup/telemetry-observability-startup-capability.test.ts new file mode 100644 index 00000000000..36006d50441 --- /dev/null +++ b/src/main/startup/telemetry-observability-startup-capability.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +const telemetryMocks = vi.hoisted(() => ({ + classifyError: vi.fn(), + initCohortClassifier: vi.fn(), + initObservability: vi.fn(), + initOnboardingCohortClassifier: vi.fn(), + initTelemetry: vi.fn(), + resolveConsent: vi.fn(), + shutdownObservability: vi.fn(), + shutdownTelemetry: vi.fn(), + track: vi.fn(), + trackAppOpenedOnce: vi.fn() +})) + +vi.mock('../observability', () => ({ + initObservability: telemetryMocks.initObservability, + shutdownObservability: telemetryMocks.shutdownObservability +})) +vi.mock('../telemetry/classify-error', () => ({ + classifyError: telemetryMocks.classifyError +})) +vi.mock('../telemetry/client', () => ({ + initTelemetry: telemetryMocks.initTelemetry, + shutdownTelemetry: telemetryMocks.shutdownTelemetry, + track: telemetryMocks.track, + trackAppOpenedOnce: telemetryMocks.trackAppOpenedOnce +})) +vi.mock('../telemetry/cohort-classifier', () => ({ + initCohortClassifier: telemetryMocks.initCohortClassifier +})) +vi.mock('../telemetry/consent', () => ({ + resolveConsent: telemetryMocks.resolveConsent +})) +vi.mock('../telemetry/onboarding-cohort-classifier', () => ({ + initOnboardingCohortClassifier: telemetryMocks.initOnboardingCohortClassifier +})) + +import { createTelemetryObservabilityStartupCapability } from './telemetry-observability-startup-capability' + +describe('telemetry/observability startup capability', () => { + it('returns every original function identity', () => { + expect(createTelemetryObservabilityStartupCapability()).toEqual(telemetryMocks) + }) +}) diff --git a/src/main/startup/telemetry-observability-startup-capability.ts b/src/main/startup/telemetry-observability-startup-capability.ts new file mode 100644 index 00000000000..d086fa817d2 --- /dev/null +++ b/src/main/startup/telemetry-observability-startup-capability.ts @@ -0,0 +1,34 @@ +import { initObservability, shutdownObservability } from '../observability' +import { classifyError } from '../telemetry/classify-error' +import { initTelemetry, shutdownTelemetry, track, trackAppOpenedOnce } from '../telemetry/client' +import { initCohortClassifier } from '../telemetry/cohort-classifier' +import { resolveConsent } from '../telemetry/consent' +import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier' + +export type TelemetryObservabilityStartupCapability = { + classifyError: typeof classifyError + initCohortClassifier: typeof initCohortClassifier + initObservability: typeof initObservability + initOnboardingCohortClassifier: typeof initOnboardingCohortClassifier + initTelemetry: typeof initTelemetry + resolveConsent: typeof resolveConsent + shutdownObservability: typeof shutdownObservability + shutdownTelemetry: typeof shutdownTelemetry + track: typeof track + trackAppOpenedOnce: typeof trackAppOpenedOnce +} + +export function createTelemetryObservabilityStartupCapability(): TelemetryObservabilityStartupCapability { + return { + classifyError, + initCohortClassifier, + initObservability, + initOnboardingCohortClassifier, + initTelemetry, + resolveConsent, + shutdownObservability, + shutdownTelemetry, + track, + trackAppOpenedOnce + } +} diff --git a/src/main/startup/telemetry-observability-startup-owner.test.ts b/src/main/startup/telemetry-observability-startup-owner.test.ts new file mode 100644 index 00000000000..2c397af69af --- /dev/null +++ b/src/main/startup/telemetry-observability-startup-owner.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TelemetryObservabilityStartupCapability } from './telemetry-observability-startup-capability' +import { + getTelemetryObservabilityStartupCapability, + getTelemetryObservabilityStartupCapabilityIfInstalled, + installTelemetryObservabilityStartupCapability +} from './telemetry-observability-startup-owner' + +describe('telemetry/observability startup owner', () => { + it('fails closed for live consumers and exposes a safe optional quit path', () => { + expect(getTelemetryObservabilityStartupCapabilityIfInstalled()).toBeNull() + expect(() => getTelemetryObservabilityStartupCapability()).toThrow( + 'Telemetry/observability capability must be initialized before use' + ) + const capability = { + track: vi.fn(), + shutdownTelemetry: vi.fn(), + shutdownObservability: vi.fn() + } as unknown as TelemetryObservabilityStartupCapability + + installTelemetryObservabilityStartupCapability(capability) + + expect(getTelemetryObservabilityStartupCapability()).toBe(capability) + expect(getTelemetryObservabilityStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/telemetry-observability-startup-owner.ts b/src/main/startup/telemetry-observability-startup-owner.ts new file mode 100644 index 00000000000..83b26722528 --- /dev/null +++ b/src/main/startup/telemetry-observability-startup-owner.ts @@ -0,0 +1,20 @@ +import type { TelemetryObservabilityStartupCapability } from './telemetry-observability-startup-capability' + +let capability: TelemetryObservabilityStartupCapability | null = null + +export function installTelemetryObservabilityStartupCapability( + nextCapability: TelemetryObservabilityStartupCapability +): void { + capability = nextCapability +} + +export function getTelemetryObservabilityStartupCapability(): TelemetryObservabilityStartupCapability { + if (!capability) { + throw new Error('Telemetry/observability capability must be initialized before use') + } + return capability +} + +export function getTelemetryObservabilityStartupCapabilityIfInstalled(): TelemetryObservabilityStartupCapability | null { + return capability +} diff --git a/src/main/startup/terminal-runtime-startup-boundary.test.ts b/src/main/startup/terminal-runtime-startup-boundary.test.ts new file mode 100644 index 00000000000..f601ddc0d9e --- /dev/null +++ b/src/main/startup/terminal-runtime-startup-boundary.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/terminal-runtime-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = 'const terminalRuntime = createTerminalRuntimeStartupCapability()' +const capabilityInstall = 'installTerminalRuntimeStartupCapability(terminalRuntime)' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('terminal-runtime startup boundary', () => { + it('removes all four eager index targets behind one aggregate capability', () => { + expect(findImport('./ipc/pty')).toBeUndefined() + expect(findImport('./daemon/daemon-init')).toBeUndefined() + expect(findImport('./providers/local-pty-provider')).toBeUndefined() + expect(findImport('./startup/first-window-startup-services')).toBeUndefined() + expect(findImport('./startup/terminal-runtime-startup-owner')).toBeDefined() + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + expect(source.split(capabilityInstall)).toHaveLength(2) + }) + + it('installs after browser and window capabilities before terminal teardown registration', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const browserFactoryIndex = source.indexOf( + 'const browserKernel = createBrowserKernelStartupCapability()', + readyIndex + ) + const windowInstallIndex = source.indexOf( + 'installMainWindowStartupCapability(createMainWindowStartupCapability())', + browserFactoryIndex + ) + const terminalImportIndex = source.indexOf(capabilityImport, windowInstallIndex) + const terminalFactoryIndex = source.indexOf(capabilityFactory, terminalImportIndex) + const terminalInstallIndex = source.indexOf(capabilityInstall, terminalFactoryIndex) + const paneTeardownIndex = source.indexOf( + 'terminalRuntime.registerPaneKeyTeardownListener(stopSyntheticTitleSpinner)', + terminalInstallIndex + ) + const browserManagerIndex = source.indexOf( + 'const browserManager = browserKernel.browserManager', + paneTeardownIndex + ) + + expect(terminalImportIndex).toBeGreaterThan(windowInstallIndex) + expect(terminalFactoryIndex).toBeGreaterThan(terminalImportIndex) + expect(terminalInstallIndex).toBeGreaterThan(terminalFactoryIndex) + expect(paneTeardownIndex).toBeGreaterThan(terminalInstallIndex) + expect(browserManagerIndex).toBeGreaterThan(paneTeardownIndex) + expect(source.split('registerPaneKeyTeardownListener(')).toHaveLength(2) + }) + + it('preserves daemon gates, error callbacks, and all three startup promise identities', () => { + const startIndex = source.indexOf('function startTerminalRuntimeStartupServices():') + const startEnd = source.indexOf('\nfunction prepareCodexRuntimeHomeForLaunch', startIndex) + const startBlock = source.slice(startIndex, startEnd) + + expect(startBlock).toContain( + 'const { initDaemonPtyProvider, startFirstWindowStartupServices } =' + ) + expect(startBlock).toContain( + "macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode" + ) + expect(startBlock).toContain("track('daemon_start_failed', classifyError(error))") + expect(startBlock).toContain( + 'firstWindowStartupServicesReady = startupServices.firstWindowReady' + ) + expect(startBlock).toContain('localPtyStartupReady = startupServices.localPtyReady') + expect(startBlock).toContain( + 'localPtyProviderStartupReady = startupServices.localPtyProviderReady' + ) + }) + + it('keeps live provider identity, serve fallback, and headless registration ordering', () => { + const runtimeIndex = source.indexOf('createOrcaRuntimeServiceStartupCapability(store, stats, {') + const startupIndex = source.indexOf('startTerminalRuntimeStartupServices()', runtimeIndex) + const serveIndex = source.indexOf('if (serveOptions) {', startupIndex) + const localBarrierIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessIndex = source.indexOf('terminalRuntime.registerHeadlessPtyRuntime(', serveIndex) + const graphIndex = source.indexOf('runtime.syncWindowGraph(', headlessIndex) + const rpcIndex = source.indexOf('await runtimeRpc.start()', graphIndex) + const activationIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(source).toContain('getLocalProvider: () => terminalRuntime.getLocalPtyProvider()') + expect(source).toContain( + 'getSshProvider: (connectionId) => terminalRuntime.getSshPtyProvider(connectionId)' + ) + expect(source).toContain('onPtyStopped: terminalRuntime.clearProviderPtyState') + expect(source).toContain('getLocalPtyProvider() instanceof LocalPtyProvider') + expect(startupIndex).toBeGreaterThan(runtimeIndex) + expect(localBarrierIndex).toBeGreaterThan(serveIndex) + expect(headlessIndex).toBeGreaterThan(localBarrierIndex) + expect(graphIndex).toBeGreaterThan(headlessIndex) + expect(rpcIndex).toBeGreaterThan(graphIndex) + expect(activationIndex).toBeGreaterThan(rpcIndex) + }) + + it('preserves PTY kill and daemon teardown choice inside the committed quit sequence', () => { + const quitIndex = source.indexOf("app.on('will-quit', (e) => {") + const quitBlock = source.slice(quitIndex) + const statsIndex = quitBlock.indexOf('stats?.flush()') + const killIndex = quitBlock.indexOf('terminalRuntime?.killAllPty()') + const storeIndex = quitBlock.indexOf('store?.flush()') + const daemonIndex = quitBlock.indexOf('const daemonTeardown = terminalRuntime') + const devShutdownIndex = quitBlock.indexOf('terminalRuntime.shutdownDaemon()', daemonIndex) + const normalDisconnectIndex = quitBlock.indexOf( + 'terminalRuntime.disconnectDaemon()', + devShutdownIndex + ) + + expect(quitBlock).toContain( + 'const terminalRuntime = getTerminalRuntimeStartupCapabilityIfInstalled()' + ) + expect(killIndex).toBeGreaterThan(statsIndex) + expect(storeIndex).toBeGreaterThan(killIndex) + expect(daemonIndex).toBeGreaterThan(storeIndex) + expect(devShutdownIndex).toBeGreaterThan(daemonIndex) + expect(normalDisconnectIndex).toBeGreaterThan(devShutdownIndex) + }) +}) diff --git a/src/main/startup/terminal-runtime-startup-capability.test.ts b/src/main/startup/terminal-runtime-startup-capability.test.ts new file mode 100644 index 00000000000..29d5d3a2911 --- /dev/null +++ b/src/main/startup/terminal-runtime-startup-capability.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +const terminalMocks = vi.hoisted(() => ({ + clearProviderPtyState: vi.fn(), + disconnectDaemon: vi.fn(), + getLocalPtyProvider: vi.fn(), + getPtyIdForPaneKey: vi.fn(), + getSshPtyProvider: vi.fn(), + initDaemonPtyProvider: vi.fn(), + killAllPty: vi.fn(), + LocalPtyProvider: vi.fn(), + registerHeadlessPtyRuntime: vi.fn(), + registerPaneKeyTeardownListener: vi.fn(), + shutdownDaemon: vi.fn(), + startFirstWindowStartupServices: vi.fn() +})) + +vi.mock('../ipc/pty', () => ({ + clearProviderPtyState: terminalMocks.clearProviderPtyState, + getLocalPtyProvider: terminalMocks.getLocalPtyProvider, + getPtyIdForPaneKey: terminalMocks.getPtyIdForPaneKey, + getSshPtyProvider: terminalMocks.getSshPtyProvider, + killAllPty: terminalMocks.killAllPty, + registerHeadlessPtyRuntime: terminalMocks.registerHeadlessPtyRuntime, + registerPaneKeyTeardownListener: terminalMocks.registerPaneKeyTeardownListener +})) + +vi.mock('../daemon/daemon-init', () => ({ + disconnectDaemon: terminalMocks.disconnectDaemon, + initDaemonPtyProvider: terminalMocks.initDaemonPtyProvider, + shutdownDaemon: terminalMocks.shutdownDaemon +})) + +vi.mock('../providers/local-pty-provider', () => ({ + LocalPtyProvider: terminalMocks.LocalPtyProvider +})) + +vi.mock('./first-window-startup-services', () => ({ + startFirstWindowStartupServices: terminalMocks.startFirstWindowStartupServices +})) + +import { createTerminalRuntimeStartupCapability } from './terminal-runtime-startup-capability' + +describe('terminal-runtime startup capability', () => { + it('returns every original function and class identity', () => { + expect(createTerminalRuntimeStartupCapability()).toEqual(terminalMocks) + }) +}) diff --git a/src/main/startup/terminal-runtime-startup-capability.ts b/src/main/startup/terminal-runtime-startup-capability.ts new file mode 100644 index 00000000000..dc5efe8f999 --- /dev/null +++ b/src/main/startup/terminal-runtime-startup-capability.ts @@ -0,0 +1,44 @@ +import { + clearProviderPtyState, + getLocalPtyProvider, + getPtyIdForPaneKey, + getSshPtyProvider, + killAllPty, + registerHeadlessPtyRuntime, + registerPaneKeyTeardownListener +} from '../ipc/pty' +import { disconnectDaemon, initDaemonPtyProvider, shutdownDaemon } from '../daemon/daemon-init' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { startFirstWindowStartupServices } from './first-window-startup-services' + +export type TerminalRuntimeStartupCapability = { + clearProviderPtyState: typeof clearProviderPtyState + disconnectDaemon: typeof disconnectDaemon + getLocalPtyProvider: typeof getLocalPtyProvider + getPtyIdForPaneKey: typeof getPtyIdForPaneKey + getSshPtyProvider: typeof getSshPtyProvider + initDaemonPtyProvider: typeof initDaemonPtyProvider + killAllPty: typeof killAllPty + LocalPtyProvider: typeof LocalPtyProvider + registerHeadlessPtyRuntime: typeof registerHeadlessPtyRuntime + registerPaneKeyTeardownListener: typeof registerPaneKeyTeardownListener + shutdownDaemon: typeof shutdownDaemon + startFirstWindowStartupServices: typeof startFirstWindowStartupServices +} + +export function createTerminalRuntimeStartupCapability(): TerminalRuntimeStartupCapability { + return { + clearProviderPtyState, + disconnectDaemon, + getLocalPtyProvider, + getPtyIdForPaneKey, + getSshPtyProvider, + initDaemonPtyProvider, + killAllPty, + LocalPtyProvider, + registerHeadlessPtyRuntime, + registerPaneKeyTeardownListener, + shutdownDaemon, + startFirstWindowStartupServices + } +} diff --git a/src/main/startup/terminal-runtime-startup-owner.test.ts b/src/main/startup/terminal-runtime-startup-owner.test.ts new file mode 100644 index 00000000000..5f6e99423f9 --- /dev/null +++ b/src/main/startup/terminal-runtime-startup-owner.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('terminal-runtime startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before installation while permitting early-quit detection', async () => { + const { getTerminalRuntimeStartupCapability, getTerminalRuntimeStartupCapabilityIfInstalled } = + await import('./terminal-runtime-startup-owner') + + expect(getTerminalRuntimeStartupCapabilityIfInstalled()).toBeNull() + expect(() => getTerminalRuntimeStartupCapability()).toThrow( + 'Terminal-runtime capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { + getTerminalRuntimeStartupCapability, + getTerminalRuntimeStartupCapabilityIfInstalled, + installTerminalRuntimeStartupCapability + } = await import('./terminal-runtime-startup-owner') + const capability = { + clearProviderPtyState: vi.fn(), + disconnectDaemon: vi.fn(), + getLocalPtyProvider: vi.fn(), + getPtyIdForPaneKey: vi.fn(), + getSshPtyProvider: vi.fn(), + initDaemonPtyProvider: vi.fn(), + killAllPty: vi.fn(), + LocalPtyProvider: vi.fn(), + registerHeadlessPtyRuntime: vi.fn(), + registerPaneKeyTeardownListener: vi.fn(), + shutdownDaemon: vi.fn(), + startFirstWindowStartupServices: vi.fn() + } + + installTerminalRuntimeStartupCapability(capability as never) + + expect(getTerminalRuntimeStartupCapability()).toBe(capability) + expect(getTerminalRuntimeStartupCapabilityIfInstalled()).toBe(capability) + }) +}) diff --git a/src/main/startup/terminal-runtime-startup-owner.ts b/src/main/startup/terminal-runtime-startup-owner.ts new file mode 100644 index 00000000000..7a6732b395c --- /dev/null +++ b/src/main/startup/terminal-runtime-startup-owner.ts @@ -0,0 +1,20 @@ +import type { TerminalRuntimeStartupCapability } from './terminal-runtime-startup-capability' + +let capability: TerminalRuntimeStartupCapability | null = null + +export function installTerminalRuntimeStartupCapability( + nextCapability: TerminalRuntimeStartupCapability +): void { + capability = nextCapability +} + +export function getTerminalRuntimeStartupCapability(): TerminalRuntimeStartupCapability { + if (!capability) { + throw new Error('Terminal-runtime capability must be initialized before use') + } + return capability +} + +export function getTerminalRuntimeStartupCapabilityIfInstalled(): TerminalRuntimeStartupCapability | null { + return capability +} diff --git a/src/main/startup/updater-runtime-startup-boundary.test.ts b/src/main/startup/updater-runtime-startup-boundary.test.ts new file mode 100644 index 00000000000..f00214df4c0 --- /dev/null +++ b/src/main/startup/updater-runtime-startup-boundary.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') +const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false) +const capabilityModule = './startup/updater-runtime-startup-capability' +const capabilityImport = `await import('${capabilityModule}')` +const capabilityFactory = 'const updaterRuntime = createUpdaterRuntimeStartupCapability()' +const capabilityInstall = 'installUpdaterRuntimeStartupCapability(updaterRuntime)' + +function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined { + return sourceFile.statements + .filter(ts.isImportDeclaration) + .find( + (declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier + ) +} + +describe('updater-runtime startup boundary', () => { + it('removes eager updater graphs while retaining only the narrow quit-state seam', () => { + expect(findImport('./updater')).toBeUndefined() + expect(findImport('./runtime/remote-server-updater')).toBeUndefined() + expect(findImport('./updater-quit-state')).toBeDefined() + expect(findImport('./startup/updater-runtime-startup-owner')).toBeDefined() + expect(source.split(capabilityImport)).toHaveLength(2) + expect(source.split(capabilityFactory)).toHaveLength(2) + expect(source.split(capabilityInstall)).toHaveLength(2) + }) + + it('loads after retained capabilities and configures the adapter before later startup', () => { + const readyIndex = source.indexOf('void app.whenReady().then(async () => {') + const browserIndex = source.indexOf( + 'const browserKernel = createBrowserKernelStartupCapability()', + readyIndex + ) + const windowIndex = source.indexOf( + 'installMainWindowStartupCapability(createMainWindowStartupCapability())', + browserIndex + ) + const terminalIndex = source.indexOf( + 'installTerminalRuntimeStartupCapability(terminalRuntime)', + windowIndex + ) + const paneTeardownIndex = source.indexOf( + 'terminalRuntime.registerPaneKeyTeardownListener(stopSyntheticTitleSpinner)', + terminalIndex + ) + const updaterImportIndex = source.indexOf(capabilityImport, paneTeardownIndex) + const updaterFactoryIndex = source.indexOf(capabilityFactory, updaterImportIndex) + const updaterInstallIndex = source.indexOf(capabilityInstall, updaterFactoryIndex) + const configureIndex = source.indexOf( + 'updaterRuntime.configureRemoteServerUpdater({', + updaterInstallIndex + ) + const browserManagerIndex = source.indexOf( + 'const browserManager = browserKernel.browserManager', + configureIndex + ) + const runtimeRpcIndex = source.indexOf( + 'createOrcaRuntimeRpcServerStartupCapability({', + browserManagerIndex + ) + + expect(updaterImportIndex).toBeGreaterThan(paneTeardownIndex) + expect(updaterFactoryIndex).toBeGreaterThan(updaterImportIndex) + expect(updaterInstallIndex).toBeGreaterThan(updaterFactoryIndex) + expect(configureIndex).toBeGreaterThan(updaterInstallIndex) + expect(browserManagerIndex).toBeGreaterThan(configureIndex) + expect(runtimeRpcIndex).toBeGreaterThan(browserManagerIndex) + }) + + it('configures the adapter with the exact shared updater identities', () => { + const configureIndex = source.indexOf('updaterRuntime.configureRemoteServerUpdater({') + const configureEnd = source.indexOf('\n })', configureIndex) + const configuration = source.slice(configureIndex, configureEnd) + + expect(configuration).toContain('getSnapshot: updaterRuntime.getRemoteServerUpdaterSnapshot') + expect(configuration).toContain('check: updaterRuntime.checkForRemoteServerUpdate') + expect(configuration).toContain('download: updaterRuntime.downloadRemoteServerUpdate') + expect(configuration).toContain('install: updaterRuntime.installRemoteServerUpdate') + }) + + it('sets the process app version before loading or configuring updater runtime', () => { + const appVersionIndex = source.indexOf('process.env.ORCA_APP_VERSION = app.getVersion()') + const capabilityIndex = source.indexOf(capabilityImport) + const configureIndex = source.indexOf('updaterRuntime.configureRemoteServerUpdater({') + + expect(appVersionIndex).toBeGreaterThanOrEqual(0) + expect(capabilityIndex).toBeGreaterThan(appVersionIndex) + expect(configureIndex).toBeGreaterThan(capabilityIndex) + }) + + it('preserves synchronous early quit-state checks through the minimal state seam', () => { + const activationStart = source.indexOf('function runDesktopActionWhenCoreIpcReady(') + const activationEnd = source.indexOf('\nconst desktopActivationGate', activationStart) + const activationBlock = source.slice(activationStart, activationEnd) + const teardownStart = source.indexOf('function getExpectedTeardownScope(') + const teardownEnd = source.indexOf('\nfunction markRecoveryReloadInFlight', teardownStart) + const teardownBlock = source.slice(teardownStart, teardownEnd) + const beforeQuitIndex = source.indexOf("app.on('before-quit', () => {") + const willQuitIndex = source.indexOf("app.on('will-quit', (e) => {") + + expect(activationBlock).toContain('if (isQuittingForUpdate())') + expect(activationBlock).toContain('if (!isQuittingForUpdate())') + expect(teardownBlock).toContain('if (isQuitting || isQuittingForUpdate())') + expect(source.indexOf('isQuittingForUpdate()', beforeQuitIndex)).toBeLessThan(willQuitIndex) + expect(source.indexOf('isQuittingForUpdate()', willQuitIndex)).toBeGreaterThan(willQuitIndex) + }) + + it('preserves menu ordering and main-window update-install mode ownership', () => { + const checkStart = source.indexOf('function runUserInitiatedUpdateCheck(') + const checkEnd = source.indexOf('\nfunction getSystemTrayOptions', checkStart) + const checkBlock = source.slice(checkStart, checkEnd) + const ensureIndex = checkBlock.indexOf('ensureAutoUpdaterConfigured()') + const checkIndex = checkBlock.indexOf( + 'getUpdaterRuntimeStartupCapability().checkForUpdatesFromMenu(options)' + ) + const attachIndex = source.indexOf('attachMainWindowServices(') + const modeIndex = source.indexOf( + 'getUpdaterRuntimeStartupCapability().resolveUpdateInstallMode(isServeMode)', + attachIndex + ) + + expect(ensureIndex).toBeGreaterThanOrEqual(0) + expect(checkIndex).toBeGreaterThan(ensureIndex) + expect(modeIndex).toBeGreaterThan(attachIndex) + }) +}) diff --git a/src/main/startup/updater-runtime-startup-capability.test.ts b/src/main/startup/updater-runtime-startup-capability.test.ts new file mode 100644 index 00000000000..61c9b9a6dd0 --- /dev/null +++ b/src/main/startup/updater-runtime-startup-capability.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +const updaterMocks = vi.hoisted(() => ({ + checkForRemoteServerUpdate: vi.fn(), + checkForUpdatesFromMenu: vi.fn(), + configureRemoteServerUpdater: vi.fn(), + downloadRemoteServerUpdate: vi.fn(), + getRemoteServerUpdaterSnapshot: vi.fn(), + installRemoteServerUpdate: vi.fn(), + resolveUpdateInstallMode: vi.fn() +})) + +vi.mock('../updater', () => ({ + checkForRemoteServerUpdate: updaterMocks.checkForRemoteServerUpdate, + checkForUpdatesFromMenu: updaterMocks.checkForUpdatesFromMenu, + downloadRemoteServerUpdate: updaterMocks.downloadRemoteServerUpdate, + getRemoteServerUpdaterSnapshot: updaterMocks.getRemoteServerUpdaterSnapshot, + installRemoteServerUpdate: updaterMocks.installRemoteServerUpdate, + resolveUpdateInstallMode: updaterMocks.resolveUpdateInstallMode +})) + +vi.mock('../runtime/remote-server-updater', () => ({ + configureRemoteServerUpdater: updaterMocks.configureRemoteServerUpdater +})) + +import { createUpdaterRuntimeStartupCapability } from './updater-runtime-startup-capability' + +describe('updater-runtime startup capability', () => { + it('returns every original updater and adapter identity', () => { + expect(createUpdaterRuntimeStartupCapability()).toEqual(updaterMocks) + }) +}) diff --git a/src/main/startup/updater-runtime-startup-capability.ts b/src/main/startup/updater-runtime-startup-capability.ts new file mode 100644 index 00000000000..21f0c9d601e --- /dev/null +++ b/src/main/startup/updater-runtime-startup-capability.ts @@ -0,0 +1,31 @@ +import { + checkForRemoteServerUpdate, + checkForUpdatesFromMenu, + downloadRemoteServerUpdate, + getRemoteServerUpdaterSnapshot, + installRemoteServerUpdate, + resolveUpdateInstallMode +} from '../updater' +import { configureRemoteServerUpdater } from '../runtime/remote-server-updater' + +export type UpdaterRuntimeStartupCapability = { + checkForRemoteServerUpdate: typeof checkForRemoteServerUpdate + checkForUpdatesFromMenu: typeof checkForUpdatesFromMenu + configureRemoteServerUpdater: typeof configureRemoteServerUpdater + downloadRemoteServerUpdate: typeof downloadRemoteServerUpdate + getRemoteServerUpdaterSnapshot: typeof getRemoteServerUpdaterSnapshot + installRemoteServerUpdate: typeof installRemoteServerUpdate + resolveUpdateInstallMode: typeof resolveUpdateInstallMode +} + +export function createUpdaterRuntimeStartupCapability(): UpdaterRuntimeStartupCapability { + return { + checkForRemoteServerUpdate, + checkForUpdatesFromMenu, + configureRemoteServerUpdater, + downloadRemoteServerUpdate, + getRemoteServerUpdaterSnapshot, + installRemoteServerUpdate, + resolveUpdateInstallMode + } +} diff --git a/src/main/startup/updater-runtime-startup-owner.test.ts b/src/main/startup/updater-runtime-startup-owner.test.ts new file mode 100644 index 00000000000..298b981b330 --- /dev/null +++ b/src/main/startup/updater-runtime-startup-owner.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('updater-runtime startup owner', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('fails closed before the capability is installed', async () => { + const { getUpdaterRuntimeStartupCapability } = await import('./updater-runtime-startup-owner') + + expect(() => getUpdaterRuntimeStartupCapability()).toThrow( + 'Updater-runtime capability must be initialized before use' + ) + }) + + it('returns the exact installed capability identity', async () => { + const { getUpdaterRuntimeStartupCapability, installUpdaterRuntimeStartupCapability } = + await import('./updater-runtime-startup-owner') + const capability = { + checkForRemoteServerUpdate: vi.fn(), + checkForUpdatesFromMenu: vi.fn(), + configureRemoteServerUpdater: vi.fn(), + downloadRemoteServerUpdate: vi.fn(), + getRemoteServerUpdaterSnapshot: vi.fn(), + installRemoteServerUpdate: vi.fn(), + resolveUpdateInstallMode: vi.fn() + } + + installUpdaterRuntimeStartupCapability(capability as never) + + expect(getUpdaterRuntimeStartupCapability()).toBe(capability) + }) +}) diff --git a/src/main/startup/updater-runtime-startup-owner.ts b/src/main/startup/updater-runtime-startup-owner.ts new file mode 100644 index 00000000000..34d300bc4fc --- /dev/null +++ b/src/main/startup/updater-runtime-startup-owner.ts @@ -0,0 +1,16 @@ +import type { UpdaterRuntimeStartupCapability } from './updater-runtime-startup-capability' + +let capability: UpdaterRuntimeStartupCapability | null = null + +export function installUpdaterRuntimeStartupCapability( + nextCapability: UpdaterRuntimeStartupCapability +): void { + capability = nextCapability +} + +export function getUpdaterRuntimeStartupCapability(): UpdaterRuntimeStartupCapability { + if (!capability) { + throw new Error('Updater-runtime capability must be initialized before use') + } + return capability +} diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 508aa0a172a..f5bd94ffc2a 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,9 +1,11 @@ -import { app } from 'electron' import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' import { writeFile, mkdir, rm } from 'node:fs/promises' -import { join, dirname } from 'node:path' +import { dirname } from 'node:path' import type { StatsSummary } from '../../shared/types' import type { StatsEvent, StatsAggregates, StatsFile } from './types' +import { getStatsFilePath } from './stats-file-path' + +export { initStatsPath } from './stats-file-path' const STATS_SCHEMA_VERSION = 1 const MAX_EVENTS = 10_000 @@ -16,23 +18,6 @@ const MAX_COUNTED_PRS = 2_000 // (a few per session) and not latency-sensitive for the UI. const DEBOUNCE_MS = 5_000 -// Why: same timing constraint as persistence.ts — the path must be captured -// after configureDevUserDataPath() but before app.setName('Orca'). See the -// comment block in persistence.ts:20-28 for the full explanation. -let _statsFile: string | null = null - -export function initStatsPath(): void { - _statsFile = join(app.getPath('userData'), 'orca-stats.json') -} - -function getStatsFile(): string { - if (!_statsFile) { - // Safety fallback — should not be hit in normal startup. - _statsFile = join(app.getPath('userData'), 'orca-stats.json') - } - return _statsFile -} - function getDefaultAggregates(): StatsAggregates { return { totalAgentsSpawned: 0, @@ -161,7 +146,7 @@ export class StatsCollector { private load(): StatsFile { try { - const statsFile = getStatsFile() + const statsFile = getStatsFilePath() if (existsSync(statsFile)) { const raw = readFileSync(statsFile, 'utf-8') const parsed = JSON.parse(raw) as StatsFile @@ -261,7 +246,7 @@ export class StatsCollector { json: string generation: number } { - const statsFile = getStatsFile() + const statsFile = getStatsFilePath() // Trim events to bounded size before writing if (this.events.length > MAX_EVENTS) { @@ -282,7 +267,7 @@ export class StatsCollector { } private writeToDiskSync(): void { - const dir = dirname(getStatsFile()) + const dir = dirname(getStatsFilePath()) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } @@ -293,7 +278,7 @@ export class StatsCollector { } private async writeToDiskAsync(): Promise { - const dir = dirname(getStatsFile()) + const dir = dirname(getStatsFilePath()) if (!existsSync(dir)) { await mkdir(dir, { recursive: true }) } diff --git a/src/main/stats/stats-file-path.test.ts b/src/main/stats/stats-file-path.test.ts new file mode 100644 index 00000000000..d7d1ce5427d --- /dev/null +++ b/src/main/stats/stats-file-path.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +describe('stats file path', () => { + beforeEach(() => { + userDataPath = '/early-user-data' + vi.resetModules() + }) + + it('keeps the path captured before the Electron app name changes', async () => { + const { getStatsFilePath, initStatsPath } = await import('./stats-file-path') + + initStatsPath() + userDataPath = '/late-user-data' + + expect(getStatsFilePath()).toBe(join('/early-user-data', 'orca-stats.json')) + }) + + it('caches the fallback path when startup initialization was skipped', async () => { + const { getStatsFilePath } = await import('./stats-file-path') + + expect(getStatsFilePath()).toBe(join('/early-user-data', 'orca-stats.json')) + userDataPath = '/late-user-data' + expect(getStatsFilePath()).toBe(join('/early-user-data', 'orca-stats.json')) + }) +}) diff --git a/src/main/stats/stats-file-path.ts b/src/main/stats/stats-file-path.ts new file mode 100644 index 00000000000..849f3100b6f --- /dev/null +++ b/src/main/stats/stats-file-path.ts @@ -0,0 +1,16 @@ +import { app } from 'electron' +import { join } from 'node:path' + +let statsFilePath: string | null = null + +export function initStatsPath(): void { + // Why: app.setName can change userData casing, so capture the configured path before app readiness. + statsFilePath = join(app.getPath('userData'), 'orca-stats.json') +} + +export function getStatsFilePath(): string { + if (!statsFilePath) { + statsFilePath = join(app.getPath('userData'), 'orca-stats.json') + } + return statsFilePath +} diff --git a/src/main/updater-quit-state.test.ts b/src/main/updater-quit-state.test.ts new file mode 100644 index 00000000000..f9a43e38325 --- /dev/null +++ b/src/main/updater-quit-state.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('updater quit state', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('is fail-closed until an update install marks the shared state', async () => { + const { + clearUpdateInstallQuitInProgress, + isQuittingForUpdate, + markUpdateInstallQuitInProgress + } = await import('./updater-quit-state') + + expect(isQuittingForUpdate()).toBe(false) + markUpdateInstallQuitInProgress() + expect(isQuittingForUpdate()).toBe(true) + clearUpdateInstallQuitInProgress() + expect(isQuittingForUpdate()).toBe(false) + }) +}) diff --git a/src/main/updater-quit-state.ts b/src/main/updater-quit-state.ts new file mode 100644 index 00000000000..1665f673569 --- /dev/null +++ b/src/main/updater-quit-state.ts @@ -0,0 +1,13 @@ +let quittingForUpdate = false + +export function isQuittingForUpdate(): boolean { + return quittingForUpdate +} + +export function markUpdateInstallQuitInProgress(): void { + quittingForUpdate = true +} + +export function clearUpdateInstallQuitInProgress(): void { + quittingForUpdate = false +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 3f9cb2a531c..053351581f6 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,6 +44,11 @@ import { requestServeUpdateHandoff } from './serve-update-handoff' import type { LocalBuildFeed } from './local-builds/local-build-feed-server' +import { + clearUpdateInstallQuitInProgress, + markUpdateInstallQuitInProgress +} from './updater-quit-state' +export { isQuittingForUpdate } from './updater-quit-state' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -137,8 +142,6 @@ let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null // Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. let downloadInFlight = false -/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ -let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null let activeUpdateSource: 'release' | 'local' = 'release' let activeLocalBuildFeed: LocalBuildFeed | null = null @@ -653,7 +656,7 @@ async function performQuitAndInstall(): Promise { markMacQuitAndInstallInFlight() // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. - quittingForUpdate = true + markUpdateInstallQuitInProgress() try { await withUpdaterSpan({ stage: 'install' }, async (span) => { @@ -746,7 +749,7 @@ async function performQuitAndInstall(): Promise { function resetQuitForUpdateState(): void { quitAndInstallInProgress = false - quittingForUpdate = false + clearUpdateInstallQuitInProgress() updateInstallCommitted = false quitAndInstallNativeInvoked = false disarmUpdateInstallExitWatchdog() @@ -1466,10 +1469,6 @@ async function checkForLocalBuildFromMenu(): Promise { } } -export function isQuittingForUpdate(): boolean { - return quittingForUpdate -} - export function quitAndInstall(): void { if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) { return diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 833f158fc56..31362a700e6 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -86,10 +86,12 @@ vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) -vi.mock('../browser/browser-manager', () => ({ - browserManager: { - unregisterAll: browserManagerUnregisterAllMock - } +vi.mock('../browser/browser-kernel-window-dependencies', () => ({ + getBrowserKernelWindowDependencies: () => ({ + browserManager: { + unregisterAll: browserManagerUnregisterAllMock + } + }) })) vi.mock('../updater', () => ({ diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 21a5654e587..1ff81c0effc 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -27,7 +27,7 @@ import { import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' -import { browserManager } from '../browser/browser-manager' +import { getBrowserKernelWindowDependencies } from '../browser/browser-kernel-window-dependencies' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { @@ -96,6 +96,7 @@ export function attachMainWindowServices( onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { + const { browserManager } = getBrowserKernelWindowDependencies() registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime, { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index fc38ca95e77..8a0f68df68a 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -60,11 +60,14 @@ vi.mock('../app-icon', () => ({ getAppIconPath: vi.fn(() => 'icon') })) -vi.mock('../browser/browser-manager', () => ({ - browserManager: { - attachGuestPolicies: attachGuestPoliciesMock, - setDictationShortcutForwardingPredicate: vi.fn() - } +vi.mock('../browser/browser-kernel-window-dependencies', () => ({ + getBrowserKernelWindowDependencies: () => ({ + browserManager: { + attachGuestPolicies: attachGuestPoliciesMock, + setDictationShortcutForwardingPredicate: vi.fn() + }, + isAllowedSessionPartition: () => true + }) })) import { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 519e1817051..0bb98dc136b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -13,8 +13,7 @@ import { join } from 'node:path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' -import { browserManager } from '../browser/browser-manager' -import { browserSessionRegistry } from '../browser/browser-session-registry' +import { getBrowserKernelWindowDependencies } from '../browser/browser-kernel-window-dependencies' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' @@ -216,6 +215,7 @@ export function createMainWindow( store: Store | null, opts?: CreateMainWindowOptions ): BrowserWindow { + const { browserManager, isAllowedSessionPartition } = getBrowserKernelWindowDependencies() const rawSavedBounds = store?.getUI().windowBounds // Why: reject min-size or substantially off-screen bounds so the titlebar stays reachable after display changes. const savedBounds = @@ -447,7 +447,7 @@ export function createMainWindow( const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : '' // Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest. - if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) { + if (!normalizedSrc || !isAllowedSessionPartition(partition)) { event.preventDefault() return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9431db3396a..dc35a768f9e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -971,6 +971,8 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Whether this renderer should schedule startup measurement milestones. */ + startupDiagnosticsEnabled: boolean /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ startupDiagnostic: (event: string, details?: Record) => Promise /** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`). diff --git a/src/preload/index.ts b/src/preload/index.ts index 8df862d0c4c..09d82ad6aba 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -487,6 +487,7 @@ const api = { }, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + startupDiagnosticsEnabled, startupDiagnostic: (event: string, details?: Record): Promise => startupDiagnosticsEnabled ? ipcRenderer.invoke('app:startupDiagnostic', event, details) diff --git a/src/preload/startup-diagnostics-contract.test.ts b/src/preload/startup-diagnostics-contract.test.ts new file mode 100644 index 00000000000..0655dc43d3f --- /dev/null +++ b/src/preload/startup-diagnostics-contract.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' + +const { exposeInMainWorld, invoke } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { + invoke, + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() + }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('preload startup diagnostics contract', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + const originalDiagnostics = process.env.ORCA_STARTUP_DIAGNOSTICS + + beforeEach(() => { + vi.resetModules() + exposeInMainWorld.mockReset() + invoke.mockReset() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalDiagnostics === undefined) { + delete process.env.ORCA_STARTUP_DIAGNOSTICS + } else { + process.env.ORCA_STARTUP_DIAGNOSTICS = originalDiagnostics + } + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + async function loadApi(enabled: boolean): Promise { + if (enabled) { + process.env.ORCA_STARTUP_DIAGNOSTICS = '1' + } else { + delete process.env.ORCA_STARTUP_DIAGNOSTICS + } + await import('./index') + return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + } + + it('exposes the enabled state and forwards renderer milestones when enabled', async () => { + const api = await loadApi(true) + + expect(api.app.startupDiagnosticsEnabled).toBe(true) + await api.app.startupDiagnostic('renderer-first-react-commit', { rendererT: 12 }) + expect(invoke).toHaveBeenCalledWith('app:startupDiagnostic', 'renderer-first-react-commit', { + rendererT: 12 + }) + }) + + it('keeps disabled milestones off IPC', async () => { + const api = await loadApi(false) + + expect(api.app.startupDiagnosticsEnabled).toBe(false) + await api.app.startupDiagnostic('renderer-first-react-commit') + expect(invoke).not.toHaveBeenCalledWith( + 'app:startupDiagnostic', + 'renderer-first-react-commit', + undefined + ) + }) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 26a434525a4..bdf0924ffcb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,7 @@ import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' -import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' +import { SkillFreshnessUpdateDialogHost } from './components/skills/SkillFreshnessUpdateDialogHost' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -147,10 +147,11 @@ import { timeRendererStartupSyncStep } from './startup/startup-diagnostics' import { reconnectSshTargetForRendererStartup } from './startup/ssh-startup-reconnect' +import { loadSettingsModule } from './components/settings/settings-module-loader' import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' import { getSystemPrefersDark } from './lib/terminal-theme' -import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' +import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-view-start' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -204,7 +205,7 @@ import { isGitRepoKind } from '../../shared/repo-kind' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' -import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import PinnedTabCloseDialogHost from './components/terminal-pane/PinnedTabCloseDialogHost' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { hasRequestedBackgroundTerminalWorktreeMount, @@ -325,7 +326,7 @@ const WorktreeCreationPanel = lazy( const TaskPage = lazy(() => import('./components/TaskPage')) const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) -const Settings = lazy(() => import('./components/settings/Settings')) +const Settings = lazy(loadSettingsModule) const SkillsPage = lazy(() => import('./components/skills/SkillsPage')) const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage')) const MobilePage = lazy(() => import('./components/mobile/MobilePage')) @@ -2738,7 +2739,7 @@ function App(): React.JSX.Element { surface="overlay" compact > - + - + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 31015a530a9..2b6ddf5c0b1 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -304,6 +304,18 @@ describe('renderer startup runtime routing', () => { ) }) + it('loads Linear agent setup implementation only after the prompt opens it', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'), + 'utf8' + ) + + expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')") + expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") + expect(source).toContain('const setupDialog = setupDialogOpen ? (') + expect(source).toContain('') + }) + it('does not eagerly import optional status-bar segments on startup', () => { const source = readFileSync( join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'), diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 6610efe2605..2a6337e21b8 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -9,23 +9,28 @@ import type { } from '../../../../shared/dashboard-snapshot' import { AgentTerminalDialog } from './AgentTerminalDialog' +const previewModuleLoad = vi.hoisted(() => vi.fn()) + // Stub the preview so the assertion is on the props the dialog hands it, with // no xterm / IPC machinery in the way. -vi.mock('./AgentTerminalPreview', () => ({ - AgentTerminalPreview: ({ - ptyId, - terminalInput - }: { - ptyId: string - terminalInput?: DashboardCardTerminalInput | null - }) => ( -
- ) -})) +vi.mock('./AgentTerminalPreview', () => { + previewModuleLoad() + return { + AgentTerminalPreview: ({ + ptyId, + terminalInput + }: { + ptyId: string + terminalInput?: DashboardCardTerminalInput | null + }) => ( +
+ ) + } +}) const TERMINAL_INPUT: DashboardCardTerminalInput = { hostPlatform: 'win32', @@ -65,7 +70,25 @@ afterEach(() => { // emulator. Dropping the prop degrades every preview to client-OS byte routing // silently — nothing else in the app reads DashboardCard.terminalInput. describe('AgentTerminalDialog', () => { - it("hands the card's relayed host-input profile to the preview terminal", () => { + it('loads the terminal preview only when the dialog card has a live PTY', async () => { + const { rerender } = render( + {}} + onReveal={() => {}} + /> + ) + + expect(previewModuleLoad).not.toHaveBeenCalled() + expect(screen.queryByTestId('preview')).not.toBeInTheDocument() + + rerender( {}} onReveal={() => {}} />) + + expect(await screen.findByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1') + expect(previewModuleLoad).toHaveBeenCalledOnce() + }) + + it("hands the card's relayed host-input profile to the preview terminal", async () => { render( { /> ) - expect(screen.getByTestId('preview')).toHaveAttribute( + expect(await screen.findByTestId('preview')).toHaveAttribute( 'data-terminal-input', JSON.stringify(TERMINAL_INPUT) ) }) - it('passes null when the card carries no profile, so the preview routes by client OS', () => { + it('passes null when the card carries no profile, so the preview routes by client OS', async () => { render( {}} onReveal={() => {}} />) - expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') + expect(await screen.findByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index ead43090820..726666d5cd7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { Suspense, useCallback } from 'react' import { SquareArrowOutUpRight, XIcon } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' @@ -6,8 +6,16 @@ import { agentStateLabel } from '@/components/AgentStateDot' import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import { AgentTerminalPreview } from './AgentTerminalPreview' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' + +const AgentTerminalPreview = lazyWithRetry( + () => + import('./AgentTerminalPreview').then((module) => ({ + default: module.AgentTerminalPreview + })), + { reloadKey: 'dashboard-agent-terminal-preview' } +) /** Routing payload for focusing an agent's pane in the main window. */ export type AgentRevealArgs = { @@ -96,7 +104,13 @@ export function AgentTerminalDialog({
{card.ptyId ? ( - + + } + > + + ) : (
{translate( diff --git a/src/renderer/src/components/dashboard-popout/agent-terminal-preview-lazy-boundary.test.ts b/src/renderer/src/components/dashboard-popout/agent-terminal-preview-lazy-boundary.test.ts new file mode 100644 index 00000000000..e11d162cf29 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-terminal-preview-lazy-boundary.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const dialogSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx'), + 'utf8' +) +const boardSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx'), + 'utf8' +) +const sourceFile = ts.createSourceFile( + 'AgentTerminalDialog.tsx', + dialogSource, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TSX +) + +describe('agent terminal preview lazy boundary', () => { + it('keeps the terminal graph outside the dashboard shell static imports', () => { + const staticImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .map((declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text) + + expect(staticImports).not.toContain('./AgentTerminalPreview') + expect(boardSource).not.toContain('AgentTerminalPreview') + expect(boardSource).toContain("from './AgentTerminalDialog'") + }) + + it('loads the preview through the resilient boundary only for a live PTY', () => { + expect(dialogSource).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'") + expect(dialogSource).toContain("import('./AgentTerminalPreview')") + expect(dialogSource).toContain("{ reloadKey: 'dashboard-agent-terminal-preview' }") + expect(dialogSource).toContain('{card.ptyId ? (') + expect(dialogSource).toContain( + '' + ) + }) +}) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 51c26f8c5ab..b1bc9f3fbeb 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1828,4 +1828,6 @@ function Settings(): React.JSX.Element { ) } +export { SkillFreshnessUpdateDialog } from '@/components/skills/SkillFreshnessUpdateDialog' +export { default as PinnedTabCloseDialog } from '@/components/terminal-pane/PinnedTabCloseDialog' export default Settings diff --git a/src/renderer/src/components/settings/settings-module-loader.ts b/src/renderer/src/components/settings/settings-module-loader.ts new file mode 100644 index 00000000000..b45491f67b0 --- /dev/null +++ b/src/renderer/src/components/settings/settings-module-loader.ts @@ -0,0 +1 @@ +export const loadSettingsModule = () => import('./Settings') diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx index e67b946cff9..1de868ec200 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx @@ -191,3 +191,5 @@ export function LinearAgentSkillSetupDialog({ ) } + +export default LinearAgentSkillSetupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index d05496a19fe..72fcede0b7b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -119,6 +119,7 @@ async function renderPrompt( await act(async () => { root?.render() }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 0d784cb1033..c26f7024a9a 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -147,6 +147,9 @@ async function renderPrompt( await act(async () => { root?.render() }) + if (props.surface === 'modal') { + await import('./LinearAgentSkillSetupDialog') + } await act(async () => {}) return container } @@ -178,6 +181,7 @@ function findBodyButton(label: string): HTMLButtonElement | undefined { } async function settleRender(): Promise { + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) await act(async () => {}) } @@ -253,12 +257,7 @@ describe('LinearAgentSkillSetupPrompt', () => { const unlinked = await renderPrompt({ linked: false, remote: false }) expect(unlinked.textContent).not.toContain('Set up Linear agent skill') - await act(async () => { - root?.unmount() - }) - root = null - unlinked.remove() - container = null + await unmountPrompt() const ready = await renderPrompt({ linked: true, remote: false }) expect(ready.textContent).not.toContain('Set up Linear agent skill') @@ -343,6 +342,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add") expect(mocks.panelProps.at(-1)).toEqual( @@ -425,7 +425,7 @@ describe('LinearAgentSkillSetupPrompt', () => { expect(mocks.getWslCliStatus).not.toHaveBeenCalled() }) - it('opens the terminal setup panel in a dialog only after the user asks to set up', async () => { + it('keeps the prompt usable and loads the lazy setup dialog only when requested', async () => { const rendered = await renderPrompt({ linked: true, remote: false }) expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).toBeNull() @@ -436,6 +436,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).not.toBeNull() expect(document.body.textContent).toContain('orca-linear') diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index d840807a7e6..31c648da74b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RefreshCw, TicketCheck, X } from 'lucide-react' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' @@ -16,6 +16,7 @@ import { ensureOrcaCliAvailableForAgentSkillTerminal, isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { cn } from '@/lib/utils' import { buildSkillCommandForRuntime, @@ -45,9 +46,12 @@ import { readLocalDismissed, type LinearAgentSkillPromptSettings } from './linear-agent-skill-runtime' -import { LinearAgentSkillSetupDialog } from './LinearAgentSkillSetupDialog' import { translate } from '@/i18n/i18n' +const LinearAgentSkillSetupDialog = lazyWithRetry(() => import('./LinearAgentSkillSetupDialog'), { + reloadKey: 'linear-agent-skill-setup-dialog' +}) + export const _linearAgentSkillSetupPromptInternalsForTests = { resetSessionReminders(): void { resetLinearAgentSkillSetupReminderToastState() @@ -278,69 +282,71 @@ export function LinearAgentSkillSetupPrompt({ return null } - const setupDialog = ( - window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) - : undefined - } - onBeforeOpenTerminal={async () => { - const requestIdentity = setupCheckIdentity - const writeIfCurrent = (write: () => void): void => { - writeCliStatusForIdentity(requestIdentity, write) - } - const nextStatus = + const setupDialog = setupDialogOpen ? ( + + { - writeIfCurrent(() => setCliStatus(nextCliStatus)) - } - }) - if (agentRuntime.runtime === 'wsl') { - writeIfCurrent(() => setCliStatus(nextStatus)) + ? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) + : undefined } - }} - onRecheck={async () => { - if (surface === 'modal') { - setActiveSetupCheckIdentity(setupCheckIdentity) - setSetupCheckResult('checking') - await Promise.all([refreshCliStatus(), skill.refresh()]) - return - } - await refreshCliStatus() - await skill.refresh() - }} - onOpenChange={(open) => { - if (open) { - setSetupDialogOpen(true) - return - } - if (showSuccessModal) { - closeSuccessModal() - return - } - if (surface === 'modal') { - snoozeForSession() - return - } - setSetupDialogOpen(false) - }} - onDismissPermanently={dismissPermanently} - onDone={closeSuccessModal} - /> - ) + onBeforeOpenTerminal={async () => { + const requestIdentity = setupCheckIdentity + const writeIfCurrent = (write: () => void): void => { + writeCliStatusForIdentity(requestIdentity, write) + } + const nextStatus = + agentRuntime.runtime === 'wsl' + ? await ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) + : await ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: (nextCliStatus) => { + writeIfCurrent(() => setCliStatus(nextCliStatus)) + } + }) + if (agentRuntime.runtime === 'wsl') { + writeIfCurrent(() => setCliStatus(nextStatus)) + } + }} + onRecheck={async () => { + if (surface === 'modal') { + setActiveSetupCheckIdentity(setupCheckIdentity) + setSetupCheckResult('checking') + await Promise.all([refreshCliStatus(), skill.refresh()]) + return + } + await refreshCliStatus() + await skill.refresh() + }} + onOpenChange={(open) => { + if (open) { + setSetupDialogOpen(true) + return + } + if (showSuccessModal) { + closeSuccessModal() + return + } + if (surface === 'modal') { + snoozeForSession() + return + } + setSetupDialogOpen(false) + }} + onDismissPermanently={dismissPermanently} + onDone={closeSuccessModal} + /> + + ) : null if (surface === 'modal') { return setupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx index 7ff473b49da..af2c3f85572 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx @@ -107,6 +107,7 @@ async function renderPrompt( ) }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index ea84e02c1d6..ddebff79d09 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -56,7 +56,7 @@ function eligibleInventory(): SkillFreshnessInventory { } } -vi.mock('@/hooks/useSkillFreshness', () => ({ +vi.mock('@/hooks/skill-freshness', () => ({ useSkillFreshness: () => ({ inventory: mocks.inventory, loading: false, diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index 135d860656b..e3a81bc75f6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' import { Terminal } from 'lucide-react' import { toast } from 'sonner' -import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useSkillFreshness } from '@/hooks/skill-freshness' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx index 8758a9dfc63..3c3025614fc 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ inventory: null as SkillFreshnessInventory | null })) -vi.mock('@/hooks/useSkillFreshness', () => ({ +vi.mock('@/hooks/skill-freshness', () => ({ useSkillFreshness: () => ({ inventory: mocks.inventory, loading: false, diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 6329622a1e6..8c7a18bc076 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -1,4 +1,4 @@ -import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useSkillFreshness } from '@/hooks/skill-freshness' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' import { IntegrationStatusPill } from '@/components/integration-status-pill' diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index 7c00fb26d53..0bef339381a 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { act, useState, type ReactNode } from 'react' +import { Circle, XCircle } from 'lucide-react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -11,9 +12,18 @@ import type { import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' import { consumeSkillFreshnessUpdateDialogRequest, - requestSkillFreshnessUpdateDialog + getSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog, + subscribeSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -import { _resetSkillUpdateRunStore, SKILL_UPDATE_SUCCESS_LINGER_MS } from './skill-update-run-store' +import { + acknowledgeSkillUpdateRun, + cancelSkillUpdateRun, + _resetSkillUpdateRunStore, + SKILL_UPDATE_SUCCESS_LINGER_MS, + startSkillUpdateRun, + useSkillUpdateRun +} from './skill-update-run-store' const mocks = vi.hoisted(() => ({ inventory: null as SkillFreshnessInventory | null, @@ -131,13 +141,49 @@ async function renderDialog(): Promise { document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render() + root?.render( + ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + })} + useUpdateRun={useSkillUpdateRun} + /> + ) }) } async function rerender(): Promise { await act(async () => { - root?.render() + root?.render( + ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + })} + useUpdateRun={useSkillUpdateRun} + /> + ) }) } @@ -240,6 +286,20 @@ describe('SkillFreshnessUpdateDialog', () => { expect(skillsApi.cancelUpdateRun).not.toHaveBeenCalled() }) + it('reopens the same in-flight run after closing', async () => { + await renderDialog() + await openViaRequest() + await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) + await clickButton('Close') + await openViaRequest() + + expect(container?.textContent).toContain('Updating 1 skill…') + expect( + container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') + ).toBe('pending') + expect(skillsApi.getUpdateRun).toHaveBeenCalledTimes(1) + }) + it('reports per-skill success once the run settles', async () => { await renderDialog() await openViaRequest() diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx index 8dd89233b0a..cdddc2b372b 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -6,8 +6,6 @@ import { isSkillScanIssueTruncatingScan, type SkillFreshnessInventory } from '../../../../shared/skill-freshness' -import { useSkillFreshness } from '@/hooks/useSkillFreshness' -import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' import { @@ -23,17 +21,7 @@ import { groupSkillFreshness } from './skill-freshness-grouping' import { SkillFreshnessScanIssues } from './skill-freshness-scan-issues' import { SkillUpdateRow } from './SkillUpdateRow' import { SummaryHeadline, summarizeInventory } from './skill-freshness-summary-headline' -import { - acknowledgeSkillUpdateRun, - cancelSkillUpdateRun, - startSkillUpdateRun, - useSkillUpdateRun -} from './skill-update-run-store' -import { - consumeSkillFreshnessUpdateDialogRequest, - getSkillFreshnessUpdateDialogRequest, - subscribeSkillFreshnessUpdateDialog -} from './skill-freshness-update-dialog' +import type { SkillFreshnessUpdateDialogDependencies } from './skill-freshness-update-dialog' function RunLog({ output }: { output: string }): React.JSX.Element | null { if (!output.trim()) { @@ -62,14 +50,21 @@ function RunLog({ output }: { output: string }): React.JSX.Element | null { ) } -export function SkillFreshnessUpdateDialog(): React.JSX.Element { - const state = useSkillFreshness() - const run = useSkillUpdateRun() - const open = useSyncExternalStore( - subscribeSkillFreshnessUpdateDialog, - getSkillFreshnessUpdateDialogRequest, - getSkillFreshnessUpdateDialogRequest - ) +export function SkillFreshnessUpdateDialog({ + acknowledgeUpdateRun, + cancelUpdateRun, + consumeOpenRequest, + getOpenRequest, + notifyInstalledSkillsChanged, + rowStateIcons, + startUpdateRun, + subscribeOpenRequest, + useFreshness, + useUpdateRun +}: SkillFreshnessUpdateDialogDependencies): React.JSX.Element { + const state = useFreshness() + const run = useUpdateRun() + const open = useSyncExternalStore(subscribeOpenRequest, getOpenRequest, getOpenRequest) const [copied, setCopied] = useState(false) // Why: settling a run notifies every skills surface, and that refresh nulls the @@ -147,7 +142,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { } // Why: closing never cancels. The run is owned by main and keeps going; the // status-bar segment carries it from here. - consumeSkillFreshnessUpdateDialogRequest() + consumeOpenRequest() setCopied(false) // Don't carry a finished session's rows into the next open — but a live run // keeps its own, or reopening from the status segment mid-run would land on @@ -156,13 +151,13 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { lastInventoryRef.current = null } if (showResult) { - void acknowledgeSkillUpdateRun() + void acknowledgeUpdateRun() } - notifyInstalledAgentSkillsChanged() + notifyInstalledSkillsChanged() } const handleUpdate = (names: readonly string[]): void => { - void startSkillUpdateRun(names) + void startUpdateRun(names) } const handleCopyCommand = (): void => { @@ -306,7 +301,12 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
{rows.map((row) => ( - + ))}
@@ -373,7 +373,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { variant="ghost" size="sm" disabled={isStopping} - onClick={() => void cancelSkillUpdateRun()} + onClick={() => void cancelUpdateRun()} > {isStopping ? translate( diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx new file mode 100644 index 00000000000..8adc1577f74 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx @@ -0,0 +1,64 @@ +import { Suspense, useEffect, useState, useSyncExternalStore } from 'react' +import { Circle, XCircle } from 'lucide-react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { useSkillFreshness } from '@/hooks/skill-freshness' +import { loadSettingsModule } from '@/components/settings/settings-module-loader' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' +import { + acknowledgeSkillUpdateRun, + cancelSkillUpdateRun, + startSkillUpdateRun, + useSkillUpdateRun +} from './skill-update-run-store' + +const ROW_STATE_ICONS = { failed: XCircle, pending: Circle } + +const SkillFreshnessUpdateDialog = lazyWithRetry( + () => + loadSettingsModule().then((module) => ({ + default: module.SkillFreshnessUpdateDialog + })), + { reloadKey: 'skill-freshness-update-dialog' } +) + +export function SkillFreshnessUpdateDialogHost(): React.JSX.Element | null { + useSkillUpdateRun() + const requested = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [mounted, setMounted] = useState(requested) + + useEffect(() => { + if (requested) { + setMounted(true) + } + }, [requested]) + + if (!mounted) { + return null + } + + return ( + + + + ) +} diff --git a/src/renderer/src/components/skills/SkillUpdateRow.tsx b/src/renderer/src/components/skills/SkillUpdateRow.tsx index d6d95f34783..d05e9dcfc1e 100644 --- a/src/renderer/src/components/skills/SkillUpdateRow.tsx +++ b/src/renderer/src/components/skills/SkillUpdateRow.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, CheckCircle2, ChevronDown, Circle, XCircle } from 'lucide-react' +import { AlertTriangle, CheckCircle2, ChevronDown, type LucideIcon } from 'lucide-react' import type { SkillFreshnessGroupModel } from './skill-freshness-grouping' import { translate } from '@/i18n/i18n' import { Badge } from '@/components/ui/badge' @@ -13,14 +13,29 @@ export type SkillRowState = 'available' | 'blocked' | 'pending' | 'done' | 'fail * Leading status glyph. Absent for `available` on purpose: an empty reserved box * would just indent the name past a gap with nothing in it. */ -function StateIcon({ state }: { state: SkillRowState }): React.JSX.Element | null { +export type SkillUpdateRowStateIcons = { + failed: LucideIcon + pending: LucideIcon +} + +function StateIcon({ + state, + stateIcons +}: { + state: SkillRowState + stateIcons: SkillUpdateRowStateIcons +}): React.JSX.Element | null { switch (state) { case 'done': return - case 'failed': - return - case 'pending': - return + case 'failed': { + const FailedIcon = stateIcons.failed + return + } + case 'pending': { + const PendingIcon = stateIcons.pending + return + } case 'blocked': return case 'available': @@ -66,10 +81,12 @@ function StateBadge({ state }: { state: SkillRowState }): React.JSX.Element | nu */ export function SkillUpdateRow({ group, - state + state, + stateIcons }: { group: SkillFreshnessGroupModel state: SkillRowState + stateIcons: SkillUpdateRowStateIcons }): React.JSX.Element { const locationCount = group.locations.length return ( @@ -86,7 +103,7 @@ export function SkillUpdateRow({ }`} > - + {group.name} diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog-lazy-boundary.test.tsx b/src/renderer/src/components/skills/skill-freshness-update-dialog-lazy-boundary.test.tsx new file mode 100644 index 00000000000..03efacdc453 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog-lazy-boundary.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, useEffect, useState, useSyncExternalStore, type ComponentType } from 'react' +import type * as ReactModule from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { SkillFreshnessUpdateDialogHost } from './SkillFreshnessUpdateDialogHost' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' +import { _resetSkillUpdateRunStore } from './skill-update-run-store' + +const lazyState = vi.hoisted(() => ({ + loadCalls: 0, + mounts: 0, + unmounts: 0, + reloadKey: '', + resolve: null as null | ((component: ComponentType) => void) +})) + +vi.mock('@/lib/lazy-with-retry', async () => { + const react = await vi.importActual('react') + return { + lazyWithRetry: ( + _factory: () => Promise<{ default: ComponentType }>, + options?: { reloadKey?: string } + ) => { + lazyState.reloadKey = options?.reloadKey ?? '' + return react.lazy(() => { + lazyState.loadCalls += 1 + return new Promise<{ default: ComponentType }>((resolve) => { + lazyState.resolve = (component) => resolve({ default: component }) + }) + }) + } + } +}) + +const APP_PATH = join(process.cwd(), 'src/renderer/src/App.tsx') +const HOST_PATH = join( + process.cwd(), + 'src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx' +) +const NUDGE_PATH = join(process.cwd(), 'src/renderer/src/components/skills/SkillFreshnessNudge.tsx') +const SETTINGS_PATH = join(process.cwd(), 'src/renderer/src/components/settings/Settings.tsx') +const SETTINGS_LOADER_PATH = join( + process.cwd(), + 'src/renderer/src/components/settings/settings-module-loader.ts' +) + +function DialogProbe(): React.JSX.Element { + const open = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [continuity, setContinuity] = useState(0) + + useEffect(() => { + lazyState.mounts += 1 + return () => { + lazyState.unmounts += 1 + } + }, []) + + return ( +
+ +
+ ) +} + +describe('skill freshness update-dialog lazy boundary', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + afterAll(async () => { + if (root) { + await act(async () => root?.unmount()) + } + container?.remove() + consumeSkillFreshnessUpdateDialogRequest() + _resetSkillUpdateRunStore() + }) + + it('keeps the eager host narrow and inside the link-routing context', () => { + const appSource = readFileSync(APP_PATH, 'utf8') + const hostSource = readFileSync(HOST_PATH, 'utf8') + const nudgeSource = readFileSync(NUDGE_PATH, 'utf8') + const settingsSource = readFileSync(SETTINGS_PATH, 'utf8') + const settingsLoaderSource = readFileSync(SETTINGS_LOADER_PATH, 'utf8') + const providerStart = appSource.indexOf('') + const providerEnd = appSource.indexOf('') + const host = appSource.indexOf('') + const nudge = appSource.indexOf('') + + expect(appSource).toContain( + "import { SkillFreshnessUpdateDialogHost } from './components/skills/SkillFreshnessUpdateDialogHost'" + ) + expect(appSource).not.toContain("from './components/skills/SkillFreshnessUpdateDialog'") + expect(providerStart).toBeGreaterThan(-1) + expect(host).toBeGreaterThan(providerStart) + expect(host).toBeLessThan(providerEnd) + expect(nudge).toBeGreaterThan(providerEnd) + expect(appSource).toContain('const Settings = lazy(loadSettingsModule)') + expect(hostSource).toContain('loadSettingsModule().then((module)') + expect(settingsLoaderSource).toContain("=> import('./Settings')") + expect(settingsSource).toContain( + "export { SkillFreshnessUpdateDialog } from '@/components/skills/SkillFreshnessUpdateDialog'" + ) + expect(hostSource).toContain("{ reloadKey: 'skill-freshness-update-dialog' }") + expect(hostSource).toContain('') + expect(hostSource).toContain('useFreshness={useSkillFreshness}') + expect(hostSource).not.toContain('const state = useSkillFreshness()') + expect(hostSource).not.toContain('SkillUpdateRow') + expect(nudgeSource).toContain('const state = useSkillFreshness()') + }) + + it('honors a pre-subscription request, suspends once, and preserves the loaded instance', async () => { + _resetSkillUpdateRunStore() + window.api = { + skills: { + onUpdateRun: () => () => {}, + getUpdateRun: async () => ({ state: 'idle' }) + } + } as never + requestSkillFreshnessUpdateDialog() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + expect(lazyState.loadCalls).toBe(1) + expect(lazyState.reloadKey).toBe('skill-freshness-update-dialog') + expect(container.childElementCount).toBe(0) + + await act(async () => { + lazyState.resolve?.(DialogProbe) + }) + const probe = container.querySelector('[data-dialog-probe]') + expect(probe?.getAttribute('data-open')).toBe('true') + expect(lazyState.mounts).toBe(1) + + await act(async () => { + container?.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + consumeSkillFreshnessUpdateDialogRequest() + }) + expect(probe?.getAttribute('data-open')).toBe('false') + expect(probe?.getAttribute('data-continuity')).toBe('1') + expect(lazyState.unmounts).toBe(0) + + await act(async () => { + requestSkillFreshnessUpdateDialog() + }) + expect(container.querySelector('[data-dialog-probe]')).toBe(probe) + expect(probe?.getAttribute('data-open')).toBe('true') + expect(probe?.getAttribute('data-continuity')).toBe('1') + expect(lazyState.loadCalls).toBe(1) + expect(lazyState.mounts).toBe(1) + }) +}) diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts index 14a84fa4ac8..7742081f6f8 100644 --- a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts @@ -1,6 +1,25 @@ +import type { SkillUpdateRun } from '../../../../shared/skill-freshness' +import type { SkillFreshnessState } from '@/hooks/skill-freshness' +import type { SkillUpdateRowStateIcons } from './SkillUpdateRow' + let pendingOpen = false const listeners = new Set<() => void>() +export type SkillFreshnessUpdateDialogRequestSnapshot = () => boolean +export type SkillFreshnessUpdateDialogRequestSubscriber = (listener: () => void) => () => void +export type SkillFreshnessUpdateDialogDependencies = { + acknowledgeUpdateRun: () => Promise + cancelUpdateRun: () => Promise + consumeOpenRequest: () => boolean + getOpenRequest: SkillFreshnessUpdateDialogRequestSnapshot + notifyInstalledSkillsChanged: () => void + rowStateIcons: SkillUpdateRowStateIcons + startUpdateRun: (names: readonly string[]) => Promise + subscribeOpenRequest: SkillFreshnessUpdateDialogRequestSubscriber + useFreshness: () => SkillFreshnessState + useUpdateRun: () => SkillUpdateRun +} + // Why: the nudge action can fire before the dialog subscribes. Keeping the // request as an external snapshot prevents mount ordering from losing it. export function requestSkillFreshnessUpdateDialog(): void { diff --git a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx new file mode 100644 index 00000000000..79549141192 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx @@ -0,0 +1,33 @@ +import { Suspense, useEffect, useState } from 'react' +import { loadSettingsModule } from '@/components/settings/settings-module-loader' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { useAppStore } from '@/store' + +const PinnedTabCloseDialog = lazyWithRetry( + () => + loadSettingsModule().then((module) => ({ + default: module.PinnedTabCloseDialog + })), + { reloadKey: 'pinned-tab-close-dialog' } +) + +export default function PinnedTabCloseDialogHost(): React.JSX.Element | null { + const requested = useAppStore((state) => state.pinnedTabCloseConfirm !== null) + const [mounted, setMounted] = useState(requested) + + useEffect(() => { + if (requested) { + setMounted(true) + } + }, [requested]) + + if (!mounted) { + return null + } + + return ( + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/pinned-tab-close-dialog-lazy-boundary.test.tsx b/src/renderer/src/components/terminal-pane/pinned-tab-close-dialog-lazy-boundary.test.tsx new file mode 100644 index 00000000000..8dcc3af774f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pinned-tab-close-dialog-lazy-boundary.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, useEffect, useState, type ComponentType } from 'react' +import type * as ReactModule from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import PinnedTabCloseDialogHost from './PinnedTabCloseDialogHost' + +const initialState = useAppStore.getInitialState() +const mountedRoots: Root[] = [] +const lazyState = vi.hoisted(() => ({ + loadCalls: 0, + mounts: 0, + reloadKey: '', + resolve: null as null | ((component: ComponentType) => void) +})) + +vi.mock('@/lib/lazy-with-retry', async () => { + const react = await vi.importActual('react') + return { + lazyWithRetry: ( + _factory: () => Promise<{ default: ComponentType }>, + options?: { reloadKey?: string } + ) => { + lazyState.reloadKey = options?.reloadKey ?? '' + return react.lazy(() => { + lazyState.loadCalls += 1 + return new Promise<{ default: ComponentType }>((resolve) => { + lazyState.resolve = (component) => resolve({ default: component }) + }) + }) + } + } +}) + +const APP_PATH = join(process.cwd(), 'src/renderer/src/App.tsx') +const HOST_PATH = join( + process.cwd(), + 'src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx' +) +const SETTINGS_PATH = join(process.cwd(), 'src/renderer/src/components/settings/Settings.tsx') +const SETTINGS_LOADER_PATH = join( + process.cwd(), + 'src/renderer/src/components/settings/settings-module-loader.ts' +) + +function DialogProbe(): React.JSX.Element { + const request = useAppStore((state) => state.pinnedTabCloseConfirm) + const [continuity, setContinuity] = useState(0) + + useEffect(() => { + lazyState.mounts += 1 + }, []) + + return ( +
+ +
+ ) +} + +describe('pinned-tab close-dialog lazy boundary', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + useAppStore.setState(initialState, true) + }) + + afterAll(() => { + lazyState.resolve = null + }) + + it('keeps the eager host narrow at the existing App placement', () => { + const appSource = readFileSync(APP_PATH, 'utf8') + const hostSource = readFileSync(HOST_PATH, 'utf8') + const settingsSource = readFileSync(SETTINGS_PATH, 'utf8') + const settingsLoaderSource = readFileSync(SETTINGS_LOADER_PATH, 'utf8') + const toaster = appSource.indexOf('') + const host = appSource.indexOf('') + const windowControls = appSource.indexOf('{hasCustomTitleBar && }') + + expect(appSource).toContain( + "import PinnedTabCloseDialogHost from './components/terminal-pane/PinnedTabCloseDialogHost'" + ) + expect(appSource).not.toContain("from './components/terminal-pane/PinnedTabCloseDialog'") + expect(host).toBeGreaterThan(toaster) + expect(host).toBeGreaterThan(nudge) + expect(host).toBeLessThan(windowControls) + expect(hostSource).toContain('state.pinnedTabCloseConfirm !== null') + expect(hostSource).toContain('loadSettingsModule().then((module)') + expect(hostSource).toContain("{ reloadKey: 'pinned-tab-close-dialog' }") + expect(hostSource).toContain('') + expect(settingsLoaderSource).toContain("=> import('./Settings')") + expect(settingsSource).toContain( + "export { default as PinnedTabCloseDialog } from '@/components/terminal-pane/PinnedTabCloseDialog'" + ) + }) + + it('does not load while the durable request is null', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await act(async () => { + root.render() + }) + + expect(lazyState.loadCalls).toBe(0) + expect(container.childElementCount).toBe(0) + }) + + it('loads a preexisting request once and preserves the mounted instance across reopen', async () => { + useAppStore.getState().requestPinnedTabCloseConfirm({ + tabLabel: 'Docs', + onConfirm: vi.fn() + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await act(async () => { + root.render() + }) + + expect(lazyState.loadCalls).toBe(1) + expect(lazyState.reloadKey).toBe('pinned-tab-close-dialog') + expect(container.childElementCount).toBe(0) + + await act(async () => { + lazyState.resolve?.(DialogProbe) + }) + const probe = container.querySelector('[data-dialog-probe]') + expect(probe?.getAttribute('data-open')).toBe('true') + expect(lazyState.mounts).toBe(1) + + await act(async () => { + container.querySelector('button')?.click() + useAppStore.getState().dismissPinnedTabClose() + }) + expect(probe?.getAttribute('data-open')).toBe('false') + expect(probe?.getAttribute('data-continuity')).toBe('1') + + await act(async () => { + useAppStore.getState().requestPinnedTabCloseConfirm({ + tabLabel: 'Console', + onConfirm: vi.fn() + }) + }) + expect(container.querySelector('[data-dialog-probe]')).toBe(probe) + expect(probe?.getAttribute('data-open')).toBe('true') + expect(probe?.getAttribute('data-continuity')).toBe('1') + expect(lazyState.loadCalls).toBe(1) + expect(lazyState.mounts).toBe(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 62b43f0b158..3d2285728db 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { getDefaultSettings } from '../../../../shared/constants' -import { - applyTerminalAppearance, - hexToRgba, - publishTerminalViewAttributesAtAppStart -} from './terminal-appearance' +import { applyTerminalAppearance, hexToRgba } from './terminal-appearance' +import { publishTerminalViewAttributesAtAppStart } from './terminal-view-start' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' @@ -287,6 +284,53 @@ describe('publishTerminalViewAttributesAtAppStart', () => { expect(sent).toHaveLength(1) }) + it('publishes the initial system dark or light selection', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(publishTerminalViewAttributesAtAppStart(settings, false, send)).toBe(true) + expect(sent.map((attributes) => attributes.colorSchemeMode)).toEqual(['dark', 'light']) + }) + + it('publishes a selected custom theme with composed opacity', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = { + ...getDefaultSettings('/tmp'), + terminalThemeDark: 'custom:warp:tokyo-night', + terminalBackgroundOpacity: 0.5, + terminalCustomThemes: [ + { + id: 'warp:tokyo-night', + name: 'Tokyo Night', + source: 'warp' as const, + mode: 'dark' as const, + terminal: { + background: '#1a1b26', + foreground: '#c0caf5', + black: '#15161e' + }, + importedAt: '2026-06-05T00:00:00.000Z' + } + ] + } + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(sent[0]?.background).toEqual([0x1a, 0x1b, 0x26]) + expect(sent[0]?.foreground).toEqual([0xc0, 0xca, 0xf5]) + expect(sent[0]?.ansi[0]).toEqual([0x15, 0x16, 0x1e]) + }) + it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => { _resetTerminalViewAttributesPublisherForTest() const publishMock = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index c1ff2c24f1a..6f3af965eaa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -8,6 +8,7 @@ import { resolvePaneStyleOptions, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { composeActiveTerminalTheme, hexToRgba, isHexColor } from './terminal-view-start' import { buildFontFamily } from './layout-serialization' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { @@ -18,89 +19,12 @@ import { import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' -import { HEX_COLOR_RE } from '../../../../shared/color-validation' -import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' -export function hexToRgba(hex: string, alpha: number): string { - let clean = hex.replace('#', '') - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join('') - } - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -export function isHexColor(value: string): boolean { - return HEX_COLOR_RE.test(value) -} - -// Why extracted: lets the settings preview compose the same theme without depending on PaneManager. Keep pure. -export function composeActiveTerminalTheme( - baseTheme: ITheme | null, - settings: Pick< - GlobalSettings, - 'terminalColorOverrides' | 'terminalBackgroundOpacity' | 'terminalCursorOpacity' - > -): ITheme | null { - if (!baseTheme) { - return null - } - // Why transparent ruler border: scrollbar.width enables xterm's overview ruler, whose border would paint a bright line. - // Why raised slider alpha: xterm's default (~0.2) is nearly invisible on dark bg. Before the spread so explicit theme wins. - let theme: ITheme = { - overviewRulerBorder: 'transparent', - scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)', - scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)', - scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)', - ...baseTheme - } - // Why: merge Ghostty color overrides atop the base theme so individual colors can be tweaked without losing the rest. - if (settings.terminalColorOverrides) { - theme = { ...theme, ...settings.terminalColorOverrides } - } - // Why: convert the hex background to rgba so xterm honors the opacity when allowTransparency is set. - if (settings.terminalBackgroundOpacity !== undefined && theme.background) { - theme = { - ...theme, - background: hexToRgba(theme.background, settings.terminalBackgroundOpacity) - } - } - // Why hex-only: hexToRgba expects a hex input, so named CSS cursor colors are left untouched. - if (settings.terminalCursorOpacity !== undefined && theme.cursor && isHexColor(theme.cursor)) { - theme = { - ...theme, - cursor: hexToRgba(theme.cursor, settings.terminalCursorOpacity) - } - } - return theme -} - -/** Publishes composed terminal appearance at app start so hidden-at-launch PTYs can query OSC 10/11 - * before any pane mounts (terminal-query-authority.md §Phase 6). Returns whether a publish went out. */ -export function publishTerminalViewAttributesAtAppStart( - settings: GlobalSettings | null | undefined, - systemPrefersDark: boolean, - send?: (attributes: TerminalViewAttributes) => boolean -): boolean { - if (!settings) { - return false - } - const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) - const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) - const theme = composeActiveTerminalTheme(baseTheme, settings) - return send !== undefined - ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) - : publishTerminalViewAttributes(theme, appearance.mode, settings) -} +export { composeActiveTerminalTheme, hexToRgba, isHexColor } // Value equality over composed ITheme objects (flat string slots plus the extendedAnsi array); gates the options.theme write. function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-startup-boundary.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-startup-boundary.test.ts new file mode 100644 index 00000000000..b40e2fc4381 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-startup-boundary.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const APP_PATH = join(process.cwd(), 'src/renderer/src/App.tsx') +const APPEARANCE_PATH = join( + process.cwd(), + 'src/renderer/src/components/terminal-pane/terminal-appearance.ts' +) +const STARTUP_PATH = join( + process.cwd(), + 'src/renderer/src/components/terminal-pane/terminal-view-start.ts' +) + +function sourceImports(source: string): string[] { + return [...source.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)].map((match) => match[1]) +} + +describe('terminal view-attributes startup source boundary', () => { + it('routes the App startup call through the concrete startup leaf', () => { + const appSource = readFileSync(APP_PATH, 'utf8') + const appearanceSource = readFileSync(APPEARANCE_PATH, 'utf8') + + expect(appSource).toContain("from './components/terminal-pane/terminal-view-start'") + expect(appSource).not.toMatch( + /publishTerminalViewAttributesAtAppStart[^]*from '\.\/components\/terminal-pane\/terminal-appearance'/ + ) + expect(appearanceSource).not.toContain( + 'export function publishTerminalViewAttributesAtAppStart' + ) + }) + + it('keeps the startup leaf independent of pane and runtime implementations', () => { + const startupSource = readFileSync(STARTUP_PATH, 'utf8') + const imports = sourceImports(startupSource) + const implementationImports = imports.filter((specifier) => + /pane-manager|sync-runtime-graph|terminal-pane-lifecycle|pty-(?:connection|dispatcher|transport)/.test( + specifier + ) + ) + + expect(imports).toContain('@/lib/terminal-theme') + expect(imports).toContain('./terminal-view-attributes-publisher') + expect(implementationImports).toEqual([]) + expect(startupSource).not.toContain('applyTerminalAppearance') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-start.ts b/src/renderer/src/components/terminal-pane/terminal-view-start.ts new file mode 100644 index 00000000000..c4852d7ac81 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-start.ts @@ -0,0 +1,76 @@ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' + +export function hexToRgba(hex: string, alpha: number): string { + let clean = hex.replace('#', '') + if (clean.length === 3) { + clean = clean + .split('') + .map((c) => c + c) + .join('') + } + const r = Number.parseInt(clean.slice(0, 2), 16) + const g = Number.parseInt(clean.slice(2, 4), 16) + const b = Number.parseInt(clean.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +export function isHexColor(value: string): boolean { + return HEX_COLOR_RE.test(value) +} + +export function composeActiveTerminalTheme( + baseTheme: ITheme | null, + settings: Pick< + GlobalSettings, + 'terminalColorOverrides' | 'terminalBackgroundOpacity' | 'terminalCursorOpacity' + > +): ITheme | null { + if (!baseTheme) { + return null + } + let theme: ITheme = { + overviewRulerBorder: 'transparent', + scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)', + scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)', + scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)', + ...baseTheme + } + if (settings.terminalColorOverrides) { + theme = { ...theme, ...settings.terminalColorOverrides } + } + if (settings.terminalBackgroundOpacity !== undefined && theme.background) { + theme = { + ...theme, + background: hexToRgba(theme.background, settings.terminalBackgroundOpacity) + } + } + if (settings.terminalCursorOpacity !== undefined && theme.cursor && isHexColor(theme.cursor)) { + theme = { + ...theme, + cursor: hexToRgba(theme.cursor, settings.terminalCursorOpacity) + } + } + return theme +} + +/** Publishes composed terminal appearance before any pane mount can answer OSC 10/11. */ +export function publishTerminalViewAttributesAtAppStart( + settings: GlobalSettings | null | undefined, + systemPrefersDark: boolean, + send?: (attributes: TerminalViewAttributes) => boolean +): boolean { + if (!settings) { + return false + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const theme = composeActiveTerminalTheme(baseTheme, settings) + return send !== undefined + ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) + : publishTerminalViewAttributes(theme, appearance.mode, settings) +} diff --git a/src/renderer/src/hooks/skill-freshness.ts b/src/renderer/src/hooks/skill-freshness.ts new file mode 100644 index 00000000000..91ea0c4bdc2 --- /dev/null +++ b/src/renderer/src/hooks/skill-freshness.ts @@ -0,0 +1,178 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' + +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 +let cachedInventory: SkillFreshnessInventory | null = null +let pendingInventory: Promise | null = null +let invalidationRevision = 0 +let completedRevision = -1 +let lastCompletedScanAt = 0 +let refreshSequence = 0 +let scheduledFocusRescan: number | null = null + +type SkillFreshnessSnapshot = { + inventory: SkillFreshnessInventory | null + loading: boolean + error: string | null +} + +let snapshot: SkillFreshnessSnapshot = { + inventory: null, + loading: false, + error: null +} +const subscribers = new Set<() => void>() + +function publishSnapshot(next: SkillFreshnessSnapshot): void { + if ( + snapshot.inventory === next.inventory && + snapshot.loading === next.loading && + snapshot.error === next.error + ) { + return + } + snapshot = next + for (const subscriber of subscribers) { + subscriber() + } +} + +async function loadInventory(force: boolean): Promise { + if (force) { + invalidationRevision += 1 + } + const targetRevision = invalidationRevision + for (;;) { + if (cachedInventory && completedRevision >= targetRevision) { + return cachedInventory + } + if (!pendingInventory) { + const requestRevision = invalidationRevision + const request = window.api.skills + .freshnessInventory() + .then((inventory) => { + cachedInventory = inventory + completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() + return inventory + }) + .finally(() => { + if (pendingInventory === request) { + pendingInventory = null + } + }) + pendingInventory = request + } + await pendingInventory + } +} + +async function refreshSkillFreshness(force = true): Promise { + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + const sequence = ++refreshSequence + // Why: eligibility is write authority for the draft command. Once invalidated, + // stale bytes must stop authorizing UI even if the replacement scan fails. + publishSnapshot({ inventory: null, loading: true, error: null }) + try { + const inventory = await loadInventory(force) + if (sequence === refreshSequence) { + publishSnapshot({ inventory, loading: false, error: null }) + } + } catch (cause) { + if (sequence === refreshSequence) { + publishSnapshot({ + inventory: null, + loading: false, + error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' + }) + } + } +} + +function onWindowFocus(): void { + const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) + if (cooldownRemaining <= 0) { + void refreshSkillFreshness(true) + return + } + if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + return + } + // Why: a focus event can follow an external edit. Retract stale update + // authority immediately, but keep rapid alt-tabs to one trailing disk scan. + publishSnapshot({ inventory: null, loading: true, error: null }) + scheduledFocusRescan = window.setTimeout( + () => { + scheduledFocusRescan = null + void refreshSkillFreshness(true) + }, + Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) + ) +} + +function onInstalledSkillsChanged(): void { + void refreshSkillFreshness(true) +} + +function subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + if (subscribers.size === 1) { + // Why: every consumer reads one external snapshot, so focus/install events + // install one listener and trigger one shared IPC scan regardless of UI count. + window.addEventListener('focus', onWindowFocus) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + return () => { + subscribers.delete(subscriber) + if (subscribers.size === 0) { + window.removeEventListener('focus', onWindowFocus) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + } +} + +function getSnapshot(): SkillFreshnessSnapshot { + return snapshot +} + +function ensureInventoryLoaded(): void { + if (!snapshot.inventory && !snapshot.loading) { + void refreshSkillFreshness(false) + } +} + +export type SkillFreshnessState = SkillFreshnessSnapshot & { + refresh: () => Promise +} + +export function useSkillFreshness(): SkillFreshnessState { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + ensureInventoryLoaded() + }, []) + + return { ...current, refresh: refreshSkillFreshness } +} + +export const _skillFreshnessCacheForTests = { + reset(): void { + cachedInventory = null + pendingInventory = null + invalidationRevision = 0 + completedRevision = -1 + lastCompletedScanAt = 0 + refreshSequence = 0 + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + snapshot = { inventory: null, loading: false, error: null } + } +} diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts index 91ea0c4bdc2..d9b9395e792 100644 --- a/src/renderer/src/hooks/useSkillFreshness.ts +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -1,178 +1,2 @@ -import { useEffect, useSyncExternalStore } from 'react' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' -import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' - -// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes -// every installed package; a just-completed scan stays authoritative briefly. -const FOCUS_RESCAN_COOLDOWN_MS = 15_000 -let cachedInventory: SkillFreshnessInventory | null = null -let pendingInventory: Promise | null = null -let invalidationRevision = 0 -let completedRevision = -1 -let lastCompletedScanAt = 0 -let refreshSequence = 0 -let scheduledFocusRescan: number | null = null - -type SkillFreshnessSnapshot = { - inventory: SkillFreshnessInventory | null - loading: boolean - error: string | null -} - -let snapshot: SkillFreshnessSnapshot = { - inventory: null, - loading: false, - error: null -} -const subscribers = new Set<() => void>() - -function publishSnapshot(next: SkillFreshnessSnapshot): void { - if ( - snapshot.inventory === next.inventory && - snapshot.loading === next.loading && - snapshot.error === next.error - ) { - return - } - snapshot = next - for (const subscriber of subscribers) { - subscriber() - } -} - -async function loadInventory(force: boolean): Promise { - if (force) { - invalidationRevision += 1 - } - const targetRevision = invalidationRevision - for (;;) { - if (cachedInventory && completedRevision >= targetRevision) { - return cachedInventory - } - if (!pendingInventory) { - const requestRevision = invalidationRevision - const request = window.api.skills - .freshnessInventory() - .then((inventory) => { - cachedInventory = inventory - completedRevision = Math.max(completedRevision, requestRevision) - lastCompletedScanAt = Date.now() - return inventory - }) - .finally(() => { - if (pendingInventory === request) { - pendingInventory = null - } - }) - pendingInventory = request - } - await pendingInventory - } -} - -async function refreshSkillFreshness(force = true): Promise { - if (scheduledFocusRescan !== null) { - window.clearTimeout(scheduledFocusRescan) - scheduledFocusRescan = null - } - const sequence = ++refreshSequence - // Why: eligibility is write authority for the draft command. Once invalidated, - // stale bytes must stop authorizing UI even if the replacement scan fails. - publishSnapshot({ inventory: null, loading: true, error: null }) - try { - const inventory = await loadInventory(force) - if (sequence === refreshSequence) { - publishSnapshot({ inventory, loading: false, error: null }) - } - } catch (cause) { - if (sequence === refreshSequence) { - publishSnapshot({ - inventory: null, - loading: false, - error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' - }) - } - } -} - -function onWindowFocus(): void { - const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) - if (cooldownRemaining <= 0) { - void refreshSkillFreshness(true) - return - } - if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { - return - } - // Why: a focus event can follow an external edit. Retract stale update - // authority immediately, but keep rapid alt-tabs to one trailing disk scan. - publishSnapshot({ inventory: null, loading: true, error: null }) - scheduledFocusRescan = window.setTimeout( - () => { - scheduledFocusRescan = null - void refreshSkillFreshness(true) - }, - Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) - ) -} - -function onInstalledSkillsChanged(): void { - void refreshSkillFreshness(true) -} - -function subscribe(subscriber: () => void): () => void { - subscribers.add(subscriber) - if (subscribers.size === 1) { - // Why: every consumer reads one external snapshot, so focus/install events - // install one listener and trigger one shared IPC scan regardless of UI count. - window.addEventListener('focus', onWindowFocus) - window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) - } - return () => { - subscribers.delete(subscriber) - if (subscribers.size === 0) { - window.removeEventListener('focus', onWindowFocus) - window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) - } - } -} - -function getSnapshot(): SkillFreshnessSnapshot { - return snapshot -} - -function ensureInventoryLoaded(): void { - if (!snapshot.inventory && !snapshot.loading) { - void refreshSkillFreshness(false) - } -} - -export type SkillFreshnessState = SkillFreshnessSnapshot & { - refresh: () => Promise -} - -export function useSkillFreshness(): SkillFreshnessState { - const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) - - useEffect(() => { - ensureInventoryLoaded() - }, []) - - return { ...current, refresh: refreshSkillFreshness } -} - -export const _skillFreshnessCacheForTests = { - reset(): void { - cachedInventory = null - pendingInventory = null - invalidationRevision = 0 - completedRevision = -1 - lastCompletedScanAt = 0 - refreshSequence = 0 - if (scheduledFocusRescan !== null) { - window.clearTimeout(scheduledFocusRescan) - scheduledFocusRescan = null - } - snapshot = { inventory: null, loading: false, error: null } - } -} +export { _skillFreshnessCacheForTests, useSkillFreshness } from './skill-freshness' +export type { SkillFreshnessState } from './skill-freshness' diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a980c8ed730..40adf4cae1e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -14,6 +14,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' +import { MainWindowStartupMilestones } from './startup/main-window-startup-milestones' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() @@ -58,6 +59,7 @@ function RendererRoot(): React.JSX.Element { createRoot(rootElement).render( + {window.api?.app.startupDiagnosticsEnabled ? : null} diff --git a/src/renderer/src/startup/main-window-startup-milestones.test.ts b/src/renderer/src/startup/main-window-startup-milestones.test.ts new file mode 100644 index 00000000000..f3845f19238 --- /dev/null +++ b/src/renderer/src/startup/main-window-startup-milestones.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMainWindowStartupMilestoneScheduler } from './main-window-startup-milestones' + +function createFrameHarness() { + let nextId = 0 + const frames = new Map void>() + return { + cancelFrame: vi.fn((id: number) => frames.delete(id)), + flushNext() { + const next = frames.entries().next().value as + | [number, (timestamp: number) => void] + | undefined + if (!next) { + throw new Error('No frame scheduled') + } + frames.delete(next[0]) + next[1](next[0]) + }, + pending: () => frames.size, + requestFrame: vi.fn((callback: (timestamp: number) => void) => { + const id = ++nextId + frames.set(id, callback) + return id + }) + } +} + +describe('main-window startup milestones', () => { + it('survives StrictMode cleanup and emits commit and post-commit paint once', () => { + const frames = createFrameHarness() + const log = vi.fn() + const schedule = createMainWindowStartupMilestoneScheduler({ + log, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame + }) + + const strictModeCleanup = schedule() + expect(log).toHaveBeenCalledWith('first-react-commit') + strictModeCleanup() + expect(frames.pending()).toBe(0) + + schedule() + frames.flushNext() + expect(log).toHaveBeenCalledTimes(1) + frames.flushNext() + expect(log.mock.calls).toEqual([['first-react-commit'], ['shell-painted']]) + + schedule() + expect(frames.pending()).toBe(0) + expect(log).toHaveBeenCalledTimes(2) + }) + + it('contains logging and frame-scheduler failures', () => { + const schedule = createMainWindowStartupMilestoneScheduler({ + log: () => { + throw new Error('IPC unavailable') + }, + requestFrame: () => { + throw new Error('frame unavailable') + }, + cancelFrame: () => { + throw new Error('cancel unavailable') + } + }) + + expect(() => schedule()).not.toThrow() + expect(() => schedule()()).not.toThrow() + }) +}) diff --git a/src/renderer/src/startup/main-window-startup-milestones.tsx b/src/renderer/src/startup/main-window-startup-milestones.tsx new file mode 100644 index 00000000000..3de1f44e74c --- /dev/null +++ b/src/renderer/src/startup/main-window-startup-milestones.tsx @@ -0,0 +1,86 @@ +import { useLayoutEffect } from 'react' +import { logRendererStartupDiagnostic } from './startup-diagnostics' + +type FrameCallback = (timestamp: number) => void + +type MainWindowStartupMilestoneDependencies = { + log: (event: string) => void + requestFrame: (callback: FrameCallback) => number + cancelFrame: (id: number) => void +} + +export function createMainWindowStartupMilestoneScheduler({ + log, + requestFrame, + cancelFrame +}: MainWindowStartupMilestoneDependencies): () => () => void { + let commitLogged = false + let paintLogged = false + + return () => { + let firstFrame: number | null = null + let secondFrame: number | null = null + let canceled = false + + if (!commitLogged) { + commitLogged = true + try { + log('first-react-commit') + } catch { + // Diagnostics must never affect the committed tree. + } + } + + if (!paintLogged) { + try { + firstFrame = requestFrame(() => { + if (canceled) { + return + } + try { + secondFrame = requestFrame(() => { + if (canceled || paintLogged) { + return + } + paintLogged = true + try { + log('shell-painted') + } catch { + // Diagnostics must never affect frame delivery. + } + }) + } catch { + // Diagnostics must never affect frame delivery. + } + }) + } catch { + // Diagnostics must never affect the committed tree. + } + } + + return () => { + canceled = true + for (const frame of [firstFrame, secondFrame]) { + if (frame === null) { + continue + } + try { + cancelFrame(frame) + } catch { + // Diagnostics cleanup is best-effort. + } + } + } + } +} + +const scheduleMainWindowStartupMilestones = createMainWindowStartupMilestoneScheduler({ + log: logRendererStartupDiagnostic, + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (id) => window.cancelAnimationFrame(id) +}) + +export function MainWindowStartupMilestones(): null { + useLayoutEffect(() => scheduleMainWindowStartupMilestones(), []) + return null +} diff --git a/src/renderer/src/startup/startup-diagnostics.ts b/src/renderer/src/startup/startup-diagnostics.ts index b3586c967cb..b7c82b4577c 100644 --- a/src/renderer/src/startup/startup-diagnostics.ts +++ b/src/renderer/src/startup/startup-diagnostics.ts @@ -9,7 +9,7 @@ export function logRendererStartupDiagnostic( details: StartupDiagnosticDetails = {} ): void { const api = window.api?.app - if (!api?.startupDiagnostic) { + if (!api?.startupDiagnosticsEnabled || !api.startupDiagnostic) { return } void api diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f193f035cfd..b106b251bf7 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -544,6 +544,7 @@ function createWebPreloadApi(): Partial { writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) }, awaitFirstWindowStartupServices: () => Promise.resolve(), + startupDiagnosticsEnabled: false, startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), setUnreadDockBadgeCount: () => Promise.resolve(), diff --git a/tests/e2e/main-thread-diagnostic-interval.ts b/tests/e2e/main-thread-diagnostic-interval.ts new file mode 100644 index 00000000000..c820b834a04 --- /dev/null +++ b/tests/e2e/main-thread-diagnostic-interval.ts @@ -0,0 +1,68 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { + aggregateMainThreadDiagnosticReports, + parseMainThreadDiagnosticLine, + waitForMainThreadDiagnosticDrain +} from '../../tools/benchmarks/main-thread-diagnostic-report.mjs' + +export type MainThreadDiagnosticInterval = { + activityElapsedMs: number + captureElapsedMs: number + reportCount: number + diagnosticWindowDurationMs: number + spawnCount: number + rpcCount: number + spawns: Record + rpcs: Record +} + +export async function captureMainThreadDiagnosticInterval( + electronApp: ElectronApplication, + activity: () => Promise +): Promise<{ activityResult: T; diagnostics: MainThreadDiagnosticInterval }> { + const child = electronApp.process() + const reports: Record[] = [] + let buffer = '' + const onStderr = (chunk: Buffer): void => { + buffer += chunk.toString() + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trimEnd() + buffer = buffer.slice(newlineIndex + 1) + newlineIndex = buffer.indexOf('\n') + const report = parseMainThreadDiagnosticLine(line) + if (report && !report.marker) { + reports.push(report) + } + } + } + child.stderr?.on('data', onStderr) + try { + const measuredReportStartIndex = await waitForMainThreadDiagnosticDrain(reports, { + isProcessRunning: () => child.exitCode === null + }) + const startedAt = performance.now() + const activityResult = await activity() + const activityElapsedMs = performance.now() - startedAt + await waitForMainThreadDiagnosticDrain(reports, { + isProcessRunning: () => child.exitCode === null + }) + const captureElapsedMs = performance.now() - startedAt + const totals = aggregateMainThreadDiagnosticReports(reports.slice(measuredReportStartIndex)) + return { + activityResult, + diagnostics: { + activityElapsedMs, + captureElapsedMs, + reportCount: reports.length - measuredReportStartIndex, + diagnosticWindowDurationMs: totals.diagnosticWindowDurationMs, + spawnCount: totals.spawnCount, + rpcCount: totals.rpcCount, + spawns: totals.perCommand, + rpcs: totals.perRpcMethod + } + } + } finally { + child.stderr?.off('data', onStderr) + } +} diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts index fb98eb9a123..ed9299b7a8f 100644 --- a/tests/e2e/source-control-large-file-count.spec.ts +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -26,6 +26,7 @@ import { removeLargeFileCountUntrackedTree } from './large-file-count-fixtures' import { DEFAULT_GIT_STATUS_LIMIT } from '../../src/shared/git-status-limit' +import { captureMainThreadDiagnosticInterval } from './main-thread-diagnostic-interval' // Matches the large-diff freeze budget: a blocking stall past 1s is the // "UI becomes unresponsive" symptom reported in #8013. @@ -264,6 +265,53 @@ test.describe('Source Control large file count (#8013)', () => { // failing scale must not skip the others — every scenario is a data point. test.use({ seedTestRepo: false }) + test.describe('diagnostic evidence', () => { + test.use({ orcaAppExtraEnv: { ORCA_MAIN_THREAD_DIAGNOSTICS: '1' } }) + + test('reports subprocess and RPC counts for one large-repo status interval', async ({ + orcaPage, + electronApp, + registerPostElectronShutdownCleanup + }) => { + test.setTimeout(120_000) + const modifiedFiles = Number(process.env.ORCA_DIAGNOSTIC_LARGE_FILE_COUNT ?? '1000') + const fixture = createLargeFileCountRepo({ + trackedFiles: modifiedFiles, + modifiedFiles + }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(async (repoPath) => { + const addedRepo = await window.__store?.getState().addRepoPath(repoPath) + if (!addedRepo) { + throw new Error(`isolated repo not found: ${repoPath}`) + } + }, fixture.repoPath) + try { + const evidence = await captureMainThreadDiagnosticInterval(electronApp, async () => { + return await orcaPage.evaluate( + async (repoPath) => + (await window.api.git.status({ worktreePath: repoPath })).entries.length, + fixture.repoPath + ) + }) + const report = { + mode: 'large-repository-main-thread-diagnostics', + syntheticTrackedAndModifiedFiles: modifiedFiles, + statusEntryCount: evidence.activityResult, + ...evidence.diagnostics + } + + console.log(`[large-repo-main-thread] ${JSON.stringify(report)}`) + expect(report.statusEntryCount).toBeGreaterThanOrEqual(modifiedFiles) + expect(report.spawnCount).toBeGreaterThan(0) + expect(report.rpcCount).toBeGreaterThanOrEqual(0) + } finally { + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) + } + }) + }) + test('a large untracked set under the status cap stays responsive', async ({ orcaPage, electronApp, diff --git a/tools/benchmarks/benchmark-artifact-home-sanitizer.mjs b/tools/benchmarks/benchmark-artifact-home-sanitizer.mjs new file mode 100644 index 00000000000..86ed047e5ab --- /dev/null +++ b/tools/benchmarks/benchmark-artifact-home-sanitizer.mjs @@ -0,0 +1,56 @@ +import os from 'node:os' + +function escapeRegularExpression(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function trimHomePath(home) { + return home.replace(/[\\/]+$/, '') +} + +function homePathVariants(home) { + if (home === '/' || /^[A-Za-z]:[\\/]+$/.test(home)) { + return [] + } + const trimmedHome = trimHomePath(home) + const variants = new Set([trimmedHome]) + if (/^[A-Za-z]:[\\/]/.test(trimmedHome)) { + variants.add(trimmedHome.replaceAll('\\', '/')) + variants.add(trimmedHome.replaceAll('/', '\\')) + } + return [...variants].filter(Boolean).sort((a, b) => b.length - a.length) +} + +function sanitizeArtifactString(value, homes) { + let sanitized = value + for (const home of homes) { + const boundary = /[\\/]$/.test(home) ? '' : '(?![A-Za-z0-9._-])' + const flags = /^[A-Za-z]:[\\/]/.test(home) ? 'gi' : 'g' + sanitized = sanitized.replace( + new RegExp(`${escapeRegularExpression(home)}${boundary}`, flags), + '~' + ) + } + return sanitized +} + +export function sanitizeBenchmarkArtifactHomePaths(value, home = os.homedir()) { + const homes = homePathVariants(home) + + function sanitizeNestedValue(nestedValue) { + if (typeof nestedValue === 'string') { + return sanitizeArtifactString(nestedValue, homes) + } + if (Array.isArray(nestedValue)) { + return nestedValue.map(sanitizeNestedValue) + } + if (nestedValue !== null && typeof nestedValue === 'object') { + return Object.fromEntries( + Object.entries(nestedValue).map(([key, entry]) => [key, sanitizeNestedValue(entry)]) + ) + } + return nestedValue + } + + return sanitizeNestedValue(value) +} diff --git a/tools/benchmarks/benchmark-artifact-home-sanitizer.test.mjs b/tools/benchmarks/benchmark-artifact-home-sanitizer.test.mjs new file mode 100644 index 00000000000..01c85b7f1fe --- /dev/null +++ b/tools/benchmarks/benchmark-artifact-home-sanitizer.test.mjs @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeBenchmarkArtifactHomePaths } from './benchmark-artifact-home-sanitizer.mjs' + +describe('sanitizeBenchmarkArtifactHomePaths', () => { + it('sanitizes nested macOS and Linux home paths without mutating input', () => { + const artifact = { + fixtureDir: '/Users/alice/orca fixture', + iterations: [ + { + events: [ + { + details: { + argv: [ + '/Users/alice/Applications/Orca.app/Contents/MacOS/Orca', + '--profile=/Users/alice/Library/Application Support/Orca' + ], + nested: { + stack: 'at launch (file:///Users/alice/project/main.js:1:1)', + linuxPath: '/home/builder/project' + } + } + } + ] + } + ], + untouched: { + outsideHome: '/Users/bob/project', + prefixCollision: '/Users/alice-other/project', + count: 4, + enabled: true, + missing: null + } + } + + const sanitized = sanitizeBenchmarkArtifactHomePaths(artifact, '/Users/alice') + + expect(sanitized).toEqual({ + fixtureDir: '~/orca fixture', + iterations: [ + { + events: [ + { + details: { + argv: [ + '~/Applications/Orca.app/Contents/MacOS/Orca', + '--profile=~/Library/Application Support/Orca' + ], + nested: { + stack: 'at launch (file://~/project/main.js:1:1)', + linuxPath: '/home/builder/project' + } + } + } + ] + } + ], + untouched: { + outsideHome: '/Users/bob/project', + prefixCollision: '/Users/alice-other/project', + count: 4, + enabled: true, + missing: null + } + }) + expect(artifact.fixtureDir).toBe('/Users/alice/orca fixture') + expect(artifact.iterations[0].events[0].details.argv[0]).toContain('/Users/alice') + }) + + it('sanitizes a Linux-shaped home embedded in arrays and messages', () => { + expect( + sanitizeBenchmarkArtifactHomePaths( + { + values: [ + '/home/alice', + 'execPath=/home/alice/bin/orca', + 'outside=/opt/orca', + '/home/alice2/project' + ] + }, + '/home/alice/' + ) + ).toEqual({ + values: ['~', 'execPath=~/bin/orca', 'outside=/opt/orca', '/home/alice2/project'] + }) + }) + + it('sanitizes Windows-shaped homes with either separator and path casing', () => { + expect( + sanitizeBenchmarkArtifactHomePaths( + { + execPath: 'C:\\Users\\ALICE\\AppData\\Local\\Orca\\Orca.exe', + argv: [ + '--user-data-dir=C:/Users/Alice/App Data/Orca', + 'file:///C:/Users/Alice/project/index.js', + 'D:\\Users\\Alice\\outside.exe', + 'C:\\Users\\Alice-old\\outside.exe' + ] + }, + 'C:\\Users\\Alice' + ) + ).toEqual({ + execPath: '~\\AppData\\Local\\Orca\\Orca.exe', + argv: [ + '--user-data-dir=~/App Data/Orca', + 'file:///~/project/index.js', + 'D:\\Users\\Alice\\outside.exe', + 'C:\\Users\\Alice-old\\outside.exe' + ] + }) + }) + + it.each(['/', 'C:\\', 'D:/'])('leaves paths unchanged for filesystem-root home %s', (home) => { + const artifact = { + posixPath: '/opt/orca/bin/orca', + windowsPath: 'C:\\Program Files\\Orca\\Orca.exe', + url: 'https://example.com/assets/app.js', + nested: ['file:///usr/local/orca', { path: 'D:/Orca/orca.exe' }] + } + + expect(sanitizeBenchmarkArtifactHomePaths(artifact, home)).toEqual(artifact) + }) +}) diff --git a/tools/benchmarks/main-thread-diagnostic-report.mjs b/tools/benchmarks/main-thread-diagnostic-report.mjs new file mode 100644 index 00000000000..e2f33d9730e --- /dev/null +++ b/tools/benchmarks/main-thread-diagnostic-report.mjs @@ -0,0 +1,73 @@ +export function parseMainThreadDiagnosticLine(line) { + const match = /^\[main-thread\] (\{.*\})$/.exec(line) + if (!match) { + return null + } + try { + return JSON.parse(match[1]) + } catch { + return null + } +} + +export function aggregateMainThreadDiagnosticReports(reports) { + const perCommand = {} + const perRpcMethod = {} + let spawnCount = 0 + let rpcCount = 0 + let diagnosticWindowDurationMs = 0 + let maxGapMs = 0 + let gapsOver50Ms = 0 + let gapsOver250Ms = 0 + for (const report of reports) { + spawnCount += report.spawnCount ?? 0 + rpcCount += report.rpcCount ?? 0 + diagnosticWindowDurationMs += report.windowDurationMs ?? 5_000 + maxGapMs = Math.max(maxGapMs, report.maxGapMs ?? 0) + gapsOver50Ms += report.gapsOver50Ms ?? 0 + gapsOver250Ms += report.gapsOver250Ms ?? 0 + for (const [command, stats] of Object.entries(report.spawns ?? {})) { + const entry = (perCommand[command] ??= { count: 0, blockMsTotal: 0, blockMsMax: 0 }) + entry.count += stats.count + entry.blockMsTotal = Math.round((entry.blockMsTotal + stats.blockMsTotal) * 100) / 100 + entry.blockMsMax = Math.max(entry.blockMsMax, stats.blockMsMax) + } + for (const [method, stats] of Object.entries(report.rpcs ?? {})) { + const entry = (perRpcMethod[method] ??= { count: 0 }) + entry.count += stats.count + } + } + return { + spawnCount, + rpcCount, + diagnosticWindowDurationMs, + maxGapMs, + gapsOver50Ms, + gapsOver250Ms, + perCommand, + perRpcMethod + } +} + +export async function waitForMainThreadDiagnosticDrain( + reports, + { + timeoutMs = 10_000, + pollMs = 25, + isProcessRunning = () => true, + wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + } = {} +) { + const reportCountBeforeDrain = reports.length + const deadline = Date.now() + timeoutMs + while (reports.length === reportCountBeforeDrain) { + if (!isProcessRunning()) { + throw new Error('App exited before the warmup diagnostic drain') + } + if (Date.now() >= deadline) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for the warmup diagnostic drain`) + } + await wait(pollMs) + } + return reports.length +} diff --git a/tools/benchmarks/main-thread-diagnostic-report.test.mjs b/tools/benchmarks/main-thread-diagnostic-report.test.mjs new file mode 100644 index 00000000000..06565e1a994 --- /dev/null +++ b/tools/benchmarks/main-thread-diagnostic-report.test.mjs @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { + aggregateMainThreadDiagnosticReports, + parseMainThreadDiagnosticLine, + waitForMainThreadDiagnosticDrain +} from './main-thread-diagnostic-report.mjs' + +describe('main-thread diagnostic benchmark reports', () => { + it('parses reports and rejects unrelated or malformed lines', () => { + expect(parseMainThreadDiagnosticLine('[main-thread] {"rpcCount":2}')).toEqual({ rpcCount: 2 }) + expect(parseMainThreadDiagnosticLine('[startup] {"rpcCount":2}')).toBeNull() + expect(parseMainThreadDiagnosticLine('[main-thread] nope')).toBeNull() + }) + + it('aggregates subprocess, RPC, stall, and exact window totals', () => { + expect( + aggregateMainThreadDiagnosticReports([ + { + windowDurationMs: 5_001, + spawnCount: 2, + rpcCount: 1, + maxGapMs: 52, + gapsOver50Ms: 1, + spawns: { + 'git status': { count: 2, blockMsTotal: 1.25, blockMsMax: 0.75 } + }, + rpcs: { 'git.status': { count: 1 } } + }, + { + windowDurationMs: 4_999, + spawnCount: 1, + rpcCount: 2, + maxGapMs: 260, + gapsOver250Ms: 1, + spawns: { + 'git status': { count: 1, blockMsTotal: 0.5, blockMsMax: 0.5 } + }, + rpcs: { + 'git.status': { count: 1 }, + 'git.history': { count: 1 } + } + } + ]) + ).toEqual({ + spawnCount: 3, + rpcCount: 3, + diagnosticWindowDurationMs: 10_000, + maxGapMs: 260, + gapsOver50Ms: 1, + gapsOver250Ms: 1, + perCommand: { + 'git status': { count: 3, blockMsTotal: 1.75, blockMsMax: 0.75 } + }, + perRpcMethod: { + 'git.status': { count: 2 }, + 'git.history': { count: 1 } + } + }) + }) + + it('uses the next drain as the boundary before measured reports begin', async () => { + const reports = [{ rpcCount: 9 }] + const wait = vi.fn(async () => { + reports.push({ rpcCount: 4 }) + }) + + const measuredReportStartIndex = await waitForMainThreadDiagnosticDrain(reports, { wait }) + reports.push({ rpcCount: 2 }, { rpcCount: 1 }) + + expect(measuredReportStartIndex).toBe(2) + expect( + aggregateMainThreadDiagnosticReports(reports.slice(measuredReportStartIndex)) + ).toMatchObject({ rpcCount: 3 }) + }) +}) diff --git a/tools/benchmarks/main-thread-jank-bench.mjs b/tools/benchmarks/main-thread-jank-bench.mjs index 308f19683be..22508908d9a 100644 --- a/tools/benchmarks/main-thread-jank-bench.mjs +++ b/tools/benchmarks/main-thread-jank-bench.mjs @@ -10,6 +10,7 @@ * - subprocess spawns since the last report, keyed by command * ("git status", "gh api", …) with the synchronous spawn-initiation cost * that held the main thread (blockMsTotal/blockMsMax) + * - outbound remote-host RPC requests keyed only by stable protocol method * On macOS the bench also tails the unified log for the exact * "Performance Diagnostics" warnings from the issue and records which process * emitted them, so spawn churn and OS warnings can be correlated by timestamp. @@ -17,6 +18,7 @@ * Usage: * node tools/benchmarks/main-thread-jank-bench.mjs --label baseline * [--duration-s 120] [--warmup-s 20] [--fixture-dir ] + * [--tracked-file-count 10000] * [--exe ] [--headless] [--no-log-stream] * * The window must stay VISIBLE during the run: git-status polling is gated on @@ -30,6 +32,12 @@ import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join, resolve } from 'node:path' +import { sanitizeBenchmarkArtifactHomePaths } from './benchmark-artifact-home-sanitizer.mjs' +import { + aggregateMainThreadDiagnosticReports, + parseMainThreadDiagnosticLine, + waitForMainThreadDiagnosticDrain +} from './main-thread-diagnostic-report.mjs' const scriptDir = import.meta.dirname const repoRoot = resolve(scriptDir, '..', '..') @@ -40,6 +48,7 @@ function parseArgs(argv) { label: 'run', durationS: 120, warmupS: 20, + trackedFileCount: 0, fixtureDir: null, exe: null, headless: false, @@ -60,6 +69,9 @@ function parseArgs(argv) { case '--fixture-dir': args.fixtureDir = next() break + case '--tracked-file-count': + args.trackedFileCount = Number(next()) + break case '--exe': args.exe = next() break @@ -73,6 +85,9 @@ function parseArgs(argv) { throw new Error(`Unknown argument: ${argv[i]}`) } } + if (!Number.isInteger(args.trackedFileCount) || args.trackedFileCount < 0) { + throw new Error('--tracked-file-count must be a non-negative integer') + } return args } @@ -84,19 +99,27 @@ function parseArgs(argv) { * the shape Orca worktrees commonly have, which forces the effective-upstream * probe (the most spawn-heavy status path) on every poll tick. */ -function ensureFixture(fixtureDir) { +function ensureFixture(fixtureDir, trackedFileCount) { mkdirSync(fixtureDir, { recursive: true }) - const repoPath = join(fixtureDir, 'bench-repo') + const fixtureSuffix = trackedFileCount > 0 ? `-${trackedFileCount}-files` : '' + const repoPath = join(fixtureDir, `bench-repo${fixtureSuffix}`) if (!existsSync(join(repoPath, '.git'))) { mkdirSync(repoPath, { recursive: true }) run('git', ['init', repoPath]) run('git', ['-C', repoPath, 'config', 'user.email', 'bench@example.com']) run('git', ['-C', repoPath, 'config', 'user.name', 'Bench']) writeFileSync(join(repoPath, 'README.md'), '# bench\n') + if (trackedFileCount > 0) { + const trackedFilesDir = join(repoPath, 'large-repo-files') + mkdirSync(trackedFilesDir, { recursive: true }) + for (let index = 0; index < trackedFileCount; index++) { + writeFileSync(join(trackedFilesDir, `${String(index).padStart(7, '0')}.txt`), `${index}\n`) + } + } run('git', ['-C', repoPath, 'add', '.']) run('git', ['-C', repoPath, 'commit', '-m', 'init', '--no-gpg-sign']) } - const originPath = join(fixtureDir, 'bench-origin.git') + const originPath = join(fixtureDir, `bench-origin${fixtureSuffix}.git`) if (!existsSync(originPath)) { run('git', ['init', '--bare', originPath]) run('git', ['-C', repoPath, 'remote', 'add', 'origin', originPath]) @@ -172,18 +195,6 @@ function run(command, args) { } } -function parseMainThreadLine(line) { - const match = /^\[main-thread\] (\{.*\})$/.exec(line) - if (!match) { - return null - } - try { - return JSON.parse(match[1]) - } catch { - return null - } -} - /** * Tail the macOS unified log for the exact warning from issue #7576. Events * are kept with process identity + wall-clock timestamp so they can be lined @@ -249,34 +260,15 @@ function killProcessTree(proc) { } } -function aggregate(reports) { - const perCommand = {} - let spawnCount = 0 - let maxGapMs = 0 - let gapsOver50Ms = 0 - let gapsOver250Ms = 0 - for (const report of reports) { - spawnCount += report.spawnCount ?? 0 - maxGapMs = Math.max(maxGapMs, report.maxGapMs ?? 0) - gapsOver50Ms += report.gapsOver50Ms ?? 0 - gapsOver250Ms += report.gapsOver250Ms ?? 0 - for (const [command, stats] of Object.entries(report.spawns ?? {})) { - const entry = (perCommand[command] ??= { count: 0, blockMsTotal: 0, blockMsMax: 0 }) - entry.count += stats.count - entry.blockMsTotal = Math.round((entry.blockMsTotal + stats.blockMsTotal) * 100) / 100 - entry.blockMsMax = Math.max(entry.blockMsMax, stats.blockMsMax) - } - } - return { spawnCount, maxGapMs, gapsOver50Ms, gapsOver250Ms, perCommand } -} - async function main() { const args = parseArgs(process.argv) const fixtureDir = resolve( args.fixtureDir ?? join(repoRoot, '.bench-fixtures', 'main-thread-jank') ) - const repoPath = ensureFixture(fixtureDir) - console.log(`[fixture] userData=${fixtureDir} repo=${repoPath}`) + const repoPath = ensureFixture(fixtureDir, args.trackedFileCount) + console.log( + `[fixture] userData=${fixtureDir} repo=${repoPath} trackedFiles=${args.trackedFileCount}` + ) // Why: the jank probe needs a stable workload and must never scan or mutate // a developer Codex profile while running against synthetic userData. @@ -323,7 +315,7 @@ async function main() { const line = buffer.slice(0, newlineIndex).trimEnd() buffer = buffer.slice(newlineIndex + 1) newlineIndex = buffer.indexOf('\n') - const report = parseMainThreadLine(line) + const report = parseMainThreadDiagnosticLine(line) if (report) { // Marker lines (e.g. updater-check-attempt) timestamp one-off // activities for correlation; they are not 5s report windows. @@ -344,14 +336,17 @@ async function main() { exitedEarly = true }) - const totalMs = (args.warmupS + args.durationS) * 1000 + console.log(`[bench] pid=${child.pid} warming up ${args.warmupS}s…`) + await new Promise((resolvePromise) => setTimeout(resolvePromise, args.warmupS * 1000)) + const measuredReportStartIndex = await waitForMainThreadDiagnosticDrain(allReports, { + isProcessRunning: () => !exitedEarly + }) + const measurementStartedAtWallMs = Date.now() console.log( - `[bench] pid=${child.pid} warming up ${args.warmupS}s, then measuring ${args.durationS}s…` + `[bench] warmup counters drained; measuring ${args.durationS}s from the next complete window` ) - await new Promise((resolvePromise) => setTimeout(resolvePromise, totalMs)) - - const measureStartWallMs = startedAtWallMs + args.warmupS * 1000 - const reports = allReports.filter((report) => report.wallMs >= measureStartWallMs) + await new Promise((resolvePromise) => setTimeout(resolvePromise, args.durationS * 1000)) + const reports = allReports.slice(measuredReportStartIndex) killProcessTree(child) if (logStreamChild) { killProcessTree(logStreamChild) @@ -360,8 +355,8 @@ async function main() { console.error('[bench] app exited before the measurement window completed') } - const totals = aggregate(reports) - const measuredMinutes = args.durationS / 60 + const totals = aggregateMainThreadDiagnosticReports(reports) + const diagnosticMinutes = totals.diagnosticWindowDurationMs / 60_000 const result = { label: args.label, capturedAt: new Date(startedAtWallMs).toISOString(), @@ -369,10 +364,14 @@ async function main() { config: { ...args, fixtureDir }, outcome: exitedEarly ? 'early-exit' : 'ok', measuredDurationS: args.durationS, + measurementStartedAt: new Date(measurementStartedAtWallMs).toISOString(), + diagnosticWindowDurationS: totals.diagnosticWindowDurationMs / 1_000, reportCount: reports.length, totals: { ...totals, - spawnsPerMinute: Math.round((totals.spawnCount / Math.max(measuredMinutes, 0.01)) * 10) / 10 + spawnsPerMinute: + Math.round((totals.spawnCount / Math.max(diagnosticMinutes, 0.01)) * 10) / 10, + rpcsPerMinute: Math.round((totals.rpcCount / Math.max(diagnosticMinutes, 0.01)) * 10) / 10 }, perfDiagnostics: { enabled: Boolean(logStreamChild), @@ -395,14 +394,16 @@ async function main() { resultsDir, `main-thread-jank-${args.label}-${new Date(startedAtWallMs).toISOString().replace(/[:.]/g, '-')}.json` ) - writeFileSync(outPath, JSON.stringify(result, null, 2)) + writeFileSync(outPath, JSON.stringify(sanitizeBenchmarkArtifactHomePaths(result), null, 2)) console.log(`\n[bench] label=${args.label} outcome=${result.outcome}`) - console.log(` reports: ${reports.length} (5s windows over ${args.durationS}s)`) + console.log( + ` reports: ${reports.length} complete windows (${result.diagnosticWindowDurationS}s)` + ) console.log( ` spawns: ${totals.spawnCount} total, ${result.totals.spawnsPerMinute}/min` + - ` worst event-loop stall: ${totals.maxGapMs}ms` + - ` stalls >50ms: ${totals.gapsOver50Ms}, >250ms: ${totals.gapsOver250Ms}` + `; worst event-loop stall: ${totals.maxGapMs}ms` + + `; stalls >50ms: ${totals.gapsOver50Ms}, >250ms: ${totals.gapsOver250Ms}` ) const topCommands = Object.entries(totals.perCommand).sort((a, b) => b[1].count - a[1].count) for (const [commandKey, stats] of topCommands.slice(0, 10)) { @@ -410,6 +411,11 @@ async function main() { ` ${commandKey}: ${stats.count} spawns, block ${stats.blockMsTotal}ms total / ${stats.blockMsMax}ms max` ) } + console.log(` remote RPCs: ${totals.rpcCount} total, ${result.totals.rpcsPerMinute}/min`) + const topRpcMethods = Object.entries(totals.perRpcMethod).sort((a, b) => b[1].count - a[1].count) + for (const [method, stats] of topRpcMethods.slice(0, 10)) { + console.log(` ${method}: ${stats.count} requests`) + } if (logStreamChild) { const byProcessSummary = perfDiagnosticsEvents.length ? ` (${Object.entries(result.perfDiagnostics.byProcess) diff --git a/tools/benchmarks/results/mobile-terminal-phase0-diagnostics-2026-07-29.md b/tools/benchmarks/results/mobile-terminal-phase0-diagnostics-2026-07-29.md new file mode 100644 index 00000000000..989cbb6c999 --- /dev/null +++ b/tools/benchmarks/results/mobile-terminal-phase0-diagnostics-2026-07-29.md @@ -0,0 +1,94 @@ +# Mobile terminal Phase 0 diagnostics evidence + +## Observable events + +Development builds emit bounded records under `[terminal-diagnostic]`. + +- `webview-count-snapshot`: `boundary`, `mountedWebViewCount`, + `activeMountedWebViewCount`, `inactiveMountedWebViewCount`, + `terminalRecordCount`, `terminalTabCount`, and `tabCount`. +- `process-memory-snapshot`: `platform`, `supportStatus`, `processRole`, `pid`, + `metric`, `bytes`, `byteUnit`, `sampledAtMs`, + `webContentProcessAttribution`, `limitation`, and `errorKind`. + +WebView boundaries are `mount`, `unmount`, `activity-change`, +`session-snapshot`, and `route-reset`. Counts use one development-only map +keyed by opaque per-pane mount identity; no terminal handles enter that map or +its aggregate records. Exact setup-cleanup-setup replay remains idempotent, +active transitions do not change the mounted total, and a late pre-reset +unmount cannot remove a post-reset pane with the same handle. +`terminalRecordCount` comes from the reconciled terminal records actually +rendered, `terminalTabCount` includes all terminal tabs (including pending +handles), and `tabCount` includes every tab. Disabled diagnostics allocate no +WebView identity map and never load the native memory module. + +Synthetic fixtures established these terminal-count records: + +```text +10 terminals: mounted=10 active=1 inactive=9 terminalRecords=10 terminalTabs=10 tabs=12 +50 terminals: mounted=50 active=1 inactive=49 terminalRecords=50 terminalTabs=50 tabs=52 +divergent snapshot: terminalRecords=3 terminalTabs=2 tabs=3 +``` + +## Process attribution + +- iOS reports the Orca app process's `TASK_VM_INFO.phys_footprint` in bytes as + `metric=physical-footprint`. +- Android reports the Orca app process's `Debug.MemoryInfo.totalPss`, normalized + from KiB to bytes, as `metric=proportional-set-size`. +- Both include the app-process role, ephemeral PID, native sample timestamp, + platform, and support status. Neither value is JS heap or device-wide memory. +- The one-shot sample starts only after terminal records load and at least one + terminal pane has committed; effect replay cannot load or sample it twice. +- Public iOS and Android sandbox APIs do not provide per-WKWebView/WebView + renderer-process memory attribution. Every snapshot therefore reports + `webContentProcessAttribution=unsupported-unattributed` and + `limitation=public-sandbox-api-unavailable`; app-process memory must not be + interpreted as total WebContent cost. + +Synthetic normalization evidence was 12,345 iOS footprint bytes and 2,048 +Android PSS KiB = 2,097,152 bytes. Tests also cover unsupported platforms, +wrong-platform native metrics, invalid values, and native exceptions without +copying exception messages. + +## Files + +- `mobile/app/h/[hostId]/session/[worktreeId].tsx` +- `mobile/src/session/TerminalPaneView.tsx` +- `mobile/src/session/mobile-terminal-diagnostics.ts` +- `mobile/src/session/mobile-terminal-process-memory-diagnostics.ts` +- `mobile/src/session/mobile-terminal-webview-count-diagnostics.ts` +- `mobile/src/diagnostics/mobile-process-memory-diagnostics.ts` +- Their three focused TypeScript test files +- `mobile/packages/expo-process-memory/` (local iOS/Android Expo module) +- `mobile/package.json` and `mobile/pnpm-lock.yaml` + +The local Expo-module layout has no native unit-test target; adding one would +require generated Xcode/Gradle test projects. Native module discovery was +instead verified through Expo autolinking on both platforms, while unit and +normalization behavior is covered in TypeScript. + +## Verification + +- `pnpm --dir mobile test`: 356 files passed, 2,617 tests passed, 2 + skipped. +- `pnpm --dir mobile typecheck`: passed. +- Targeted `oxlint` and `oxfmt --check`: passed. +- iOS and Android `expo-modules-autolinking resolve`: both found + `@orca/expo-process-memory`. +- `pnpm check:max-lines-ratchet`: passed with no new bypass. +- `git diff --check`: passed. + +An iOS 26.1 simulator was available, but the non-interactive smoke stalled in +CocoaPods' automatic project-update retry before native compilation. Android +had no connected device and no configured AVD. Reproduce on provisioned +environments with: + +```bash +pnpm --dir mobile exec expo run:ios --device "iPhone 17 Pro" --no-bundler +pnpm --dir mobile exec expo run:android --device --no-bundler +``` + +Open a 10- or 50-terminal session in a development build and filter Metro/device +logs for `[terminal-diagnostic]`. Physical-device validation remains required +for representative iOS physical-footprint and Android PSS values. diff --git a/tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md b/tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md new file mode 100644 index 00000000000..ce672e0dda5 --- /dev/null +++ b/tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md @@ -0,0 +1,97 @@ +# Phase 0 startup attribution — 2026-07-29 + +## Scope + +This is the durable sanitized summary of the one-run Phase 0 startup capture. +It retains only clock values, derived durations, build deltas, validation, and +limitations. Raw process arguments, executable paths, process identifiers, and +user-directory paths are intentionally excluded. + +The fixture was an empty restored state on macOS arm64 with no files, session +tabs, or GitHub repositories. The run used an unpackaged production Electron +build and waited for `renderer-shell-painted`. + +## Clock semantics + +| Clock | Meaning | +| ----------- | ------- | +| `harnessMs` | Benchmark-parent milliseconds at complete-line receipt | +| `t` | Electron main-process `performance.now()` milliseconds | +| `rendererT` | Main-window renderer `performance.now()` milliseconds | + +Main-process `t` values can be subtracted from other main-process `t` values. +Renderer `rendererT` values can be subtracted from other renderer values. +Neither renderer values nor parent receipt values are subtracted from the main +clock. + +## Observed milestone clocks + +| Milestone | Main `t` | Renderer `rendererT` | Parent receipt `harnessMs` | +| ---------------------------- | -------: | -------------------: | -------------------------: | +| Bundle enter | 257.241709 | — | 1,814.6 | +| Bundle evaluation complete | 454.719834 | — | 2,011.1 | +| App ready | 529.7 | — | 2,086.2 | +| Services initialized | 815.8 | — | 2,372.3 | +| Renderer first React commit | 1,101.0 | 194 | 2,657.5 | +| Renderer shell painted | 1,151.1 | 249 | 2,707.6 | + +## Derived phases + +All 21 numeric derived phases were non-negative. + +| Phase | Milliseconds | +| ----- | -----------: | +| Spawn to bundle-enter receipt | 1,814.6 | +| Synchronous bundle and dependency evaluation | 197.478125 | +| Bundle evaluation complete to app ready | 74.980166 | +| App ready to services initialized | 286.1 | +| Services initialized to first React commit | 285.2 | +| First React commit to shell painted | 55 | +| Total to first React commit | 2,657.5 | +| Total to shell painted | 2,707.6 | +| Startup store load | 1 | +| Spawn to app ready | 2,086.2 | +| App ready to services | 286.1 | +| Services to i18n ready | 2.2 | +| I18n ready to open-window start | 3.2 | +| Daemon initialization | 127 | +| Window created to load start | 5.4 | +| Window created to `did-finish-load` | 173.1 | +| Total to window created | 2,453.2 | +| Total to `did-finish-load` | 2,626.2 | +| `did-finish-load` to workspace ready | 143.1 | +| Total to workspace ready | 2,769.3 | +| Renderer terminal reconnect | 3 | + +`startupJsonParseMs`, `aclGrantMs`, and `maxEventLoopStallMs` were unavailable +in this capture and remain unreported rather than being treated as zero. + +## Bundle deltas + +Against the pre-instrumentation production output: + +| Surface | Raw change | Gzip change | +| -------------------- | ---------: | ----------: | +| Electron main | +697 | +286 | +| Electron preload | +29 | +3 | +| Main renderer | +1,464 | +425 | +| Total | +2,190 | +714 | + +No bundle budget was raised. + +## Validation + +The production build, bundle-budget check, focused 16-test suite, Node and web +typechecks, targeted oxlint and oxfmt checks, max-lines ratchet, and +`git diff --check` passed. + +## Limitations + +This was one cold macOS iteration from an unpackaged production Electron build, +so it is causal instrumentation evidence rather than a representative latency +baseline. JavaScript cannot time parsing or linking before its first executable +statement; spawn-to-bundle-enter also includes Electron boot, parse and link +work, complete-line delivery, and parent receipt. Main IPC receipt includes +message latency, and the double-animation-frame boundary proves a post-commit +paint opportunity rather than compositor presentation. Packaged launch and +cross-platform macOS, Linux, and Windows timing remain unresolved. diff --git a/tools/benchmarks/results/phase0-subprocess-rpc-evidence-2026-07-29.md b/tools/benchmarks/results/phase0-subprocess-rpc-evidence-2026-07-29.md new file mode 100644 index 00000000000..bf7f2d216bf --- /dev/null +++ b/tools/benchmarks/results/phase0-subprocess-rpc-evidence-2026-07-29.md @@ -0,0 +1,109 @@ +# Phase 0 subprocess/RPC evidence + +## Instrumented boundaries + +- Existing Git subprocess accounting remains in `src/main/git/runner.ts`; command + classification and spawn-initiation timing are unchanged. +- `SshChannelMultiplexer.request()` records one admitted outbound relay request + immediately before frame send. Notifications, keepalives, responses, cancel + notifications, pre-aborted calls, and disposed calls do not add a second + logical request. +- Runtime-environment one-shot, cached connection, shared-control, dedicated + subscription, and shared subscription transports record once at their common + main-process request boundary. Shared-control capability probes use the same + boundary, so their real `status.get` round trip is visible. +- Only the stable method string is retained. Invalid or path-shaped methods use + the `other` bucket; params, paths, hosts, environment IDs, pairing data, and + credentials are never retained. +- With `ORCA_MAIN_THREAD_DIAGNOSTICS` disabled, each call returns after one + environment gate and no counter entry or timer accumulates. + +## Periodic report schema + +Every complete diagnostic window emits: + +```text +[main-thread] { + t, + windowDurationMs, + maxGapMs, + gapsOver50Ms, + gapsOver250Ms, + spawnCount, + spawns: { + "": { count, blockMsTotal, blockMsMax } + }, + rpcCount, + rpcs: { + "": { count } + } +} +``` + +Subprocess and RPC maps drain in the same synchronous report callback. +`windowDurationMs` is measured rather than inferred, and the benchmark waits for +the next complete report before starting its measured slice, preventing warmup +counts from leaking into evidence. + +## Measured smoke evidence + +Focused headless Electron E2E, 100 tracked and modified files: + +- status entries: 100 +- status activity: 182.13 ms +- diagnostic capture: 5,003.31 ms +- exact diagnostic window: 5,009 ms +- Git subprocesses: 6 (`git status` 1, `git symbolic-ref` 1, + `git rev-parse` 2, `git config` 1, `git diff` 1) +- remote RPCs: 0, truthfully reflecting a local repository + +The main-thread benchmark harness smoke used 100 tracked files and one complete +5.021 s headless window. It reported 0 Git subprocesses and 0 RPCs because +headless mode intentionally disables visibility-gated Source Control polling; +the sanitized artifact is +`tools/benchmarks/results/main-thread-jank-phase0-rpc-smoke-2026-07-30T00-04-54-758Z.json`. + +The deterministic synthetic slow-SSH harness applies 125 ms of controlled +in-memory transport latency. It reports 125 ms elapsed and 3 logical requests: +`git.status`, `git.history`, and `git.diff` once each; success, remote error, and +post-send cancellation remain single-counted, while a pre-aborted request is +not counted. A send failure counts its single attempted request, and a later +disposed call does not count. + +Synthetic latency validates logical counter placement and outcome semantics; it +does not represent SSH negotiation, encryption, congestion, server execution, +or live-host tail latency. The large-repository evidence is a short local +fixture run, not a representative performance baseline. + +## Verification + +- Focused diagnostics, SSH/multiplexer/provider, runtime-provider, and benchmark + tests: 17 files, 272 tests passed. +- Focused large-repository Electron E2E: 1 passed. +- `pnpm run build:electron-vite`: passed. +- `pnpm run check:electron-bundle-budgets`: passed without budget changes. +- `pnpm typecheck:node`: passed. +- Targeted `oxfmt` and `oxlint`: passed. +- `pnpm run check:max-lines-ratchet`: passed with no new bypasses. +- `git diff --check`: passed. + +## Task files + +- `src/main/diagnostics/main-thread-churn-probe.ts` +- `src/main/diagnostics/main-thread-churn-probe.test.ts` +- `src/main/diagnostics/main-thread-churn-probe-report.test.ts` +- `src/main/ssh/ssh-channel-multiplexer.ts` +- `src/main/ssh/ssh-channel-multiplexer-synthetic-latency.benchmark.test.ts` +- `src/main/ipc/runtime-environment-request-connections.ts` +- `src/main/ipc/runtime-environment-request-connections.test.ts` +- `src/main/ipc/runtime-environment-shared-control-support.ts` +- `src/main/ipc/runtime-environment-transport-routing.ts` +- `src/main/ipc/runtime-environments.test.ts` +- `tools/benchmarks/main-thread-jank-bench.mjs` +- `tools/benchmarks/main-thread-diagnostic-report.mjs` +- `tools/benchmarks/main-thread-diagnostic-report.test.mjs` +- `tools/benchmarks/benchmark-artifact-home-sanitizer.mjs` +- `tools/benchmarks/benchmark-artifact-home-sanitizer.test.mjs` +- `tools/benchmarks/startup-time-bench.mjs` +- `tests/e2e/main-thread-diagnostic-interval.ts` +- `tests/e2e/source-control-large-file-count.spec.ts` diff --git a/tools/benchmarks/results/phase1-account-runtime-coordination-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-account-runtime-coordination-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..5e2093c8ccf --- /dev/null +++ b/tools/benchmarks/results/phase1-account-runtime-coordination-startup-boundary-2026-07-29.md @@ -0,0 +1,174 @@ +# Phase 1 account-runtime coordination startup boundary — 2026-07-29 + +**Scope:** Move the remaining direct account-target, MiniMax-cookie, Claude +runtime-selection, and live-PTY coordination values behind one exact-identity +app-ready capability without moving account policy or cross-domain path/runtime leaves. + +## Result + +`src/main/index.ts` now dynamically imports +`./startup/account-runtime-coordination-startup-capability` immediately after installing +the retained crash/hang capability. It installs the exact returned object in a typed, +fail-closed owner before crash-store setup, Store construction, live-PTY hydration, or +any account/rate-limit consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,557,573 | 3,544,763 | -12,810 | 750,742 | 748,197 | -2,545 | + +The preload and renderer outputs remained byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer audit + +Before this tranche, `index.ts` eagerly value-imported: + +- `readMiniMaxSessionCookie`; +- `getInitialClaudeRateLimitTarget`; +- `getInitialCodexRateLimitTarget`; +- `createAccountRuntimeTargetSettingsSync`; +- `normalizeClaudeRuntimeSelection`; +- `attachClaudeLivePtyPersistence`; +- `onLiveClaudePtysDrained`; and +- `seedLiveClaudePtysFromPersistence`. + +The complete production audit found: + +- `minimax-cookie-store` is also imported by rate-limit service status checks and + MiniMax credential IPC. The capability returns the same module function, preserving + the one cached cookie, safeStorage behavior, legacy-envelope migration, file + hardening, and error behavior. +- `claude-rate-limit-target` and `codex-rate-limit-target` are also composed by + `account-runtime-target-sync`; there are no other direct startup importers. +- `account-runtime-target-sync` otherwise had only the direct `index.ts` importer. +- `claude-accounts/runtime-selection` is also used by the Claude account/auth services + and Claude rate-limit targeting; window, PTY, and account IPC consumers are + type-only where applicable. +- `live-pty-gate` is also used by Claude account/auth services, PTY handling, and daemon + initialization. ES module caching preserves the one live/seeded PTY sets, persistence + target, drain-listener set, and auth-switch state across all retained dynamic graphs. + +No candidate has a pre-`app.whenReady()` call. Cross-platform path comparison, default +WSL distro selection, account/auth restart preservation, WSL/SSH providers, and +unrelated leaves remain eager and unchanged. + +## Lifecycle and identity reasoning + +The capability is installed after crash/hang capability installation and before its +first consumer. Startup order remains: + +1. create Store and register the existing settings listener; +2. attach Store as live-Claude-PTY persistence; +3. register the drain listener that refreshes rate limits; +4. read persisted Claude PTY IDs and seed the shared gate; +5. construct account services, including Claude auth; +6. set Codex then Claude initial rate-limit targets; +7. create the settings-derived target synchronizer and register its Store listener; +8. install the unchanged status-line, MiniMax, inactive-account, proxy, and other + rate-limit resolvers. + +This keeps seeding before the Claude auth constructor can refresh a surviving daemon +session's single-use token. The drain callback still observes the live `rateLimits` +singleton and uses optional access until account services exist. Initial target +resolution and settings synchronization still receive the exact same settings objects +and default `process.platform` input. + +The MiniMax and inactive-Claude resolvers are live deferred callbacks, so they read the +required owner and fail closed if invoked before installation. The owner returns the +exact installed capability; it does not wrap or duplicate any function. The inactive +Claude resolver retains the original two normalization calls and resulting host/WSL +selection order. + +No rate-limit polling policy, account selection, authentication, credential file, +runtime construction, PTY provider, shutdown behavior, or settings schema moved into +the capability. Local, WSL, SSH/remote, and folder-workspace behavior remains behind +the existing runtime/provider boundaries. No Git command or provider behavior changed. + +## A/B evidence and hashes + +Fresh production evidence: + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A: `/tmp/orca-account-runtime-coordination-a.ZJQkAo` +- B: `/tmp/orca-account-runtime-coordination-final-b.x6f9Aj` +- A transformed 1,997 main, 17 preload, and 9,181 renderer modules. +- B transformed 1,999 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry hashes: + +- A `out/main/index.js` SHA-256: + `ee3964bc76235c05c55ea67e53408dddabfa88ffec6e958de33323420c9b2fe6` +- B `out/main/index.js` SHA-256: + `19b988fd4522bc961d6e36dc5e2a807b8a6c086b8c9aae5ced4a5d722c13c018` + +The sorted A and B non-main manifests each contain exactly 786 rows and are +byte-identical. Their SHA-256 is +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; +`diff -u` produced no output. + +The A main manifest contains 135 rows with SHA-256 +`4bc1da573d6dd46d3167ce729de32f55d91f9339ce2fb974776992255d1daf13`. +The B main manifest contains 137 rows with SHA-256 +`a16db28743b70c72bfba3335c2e7c48e3dbd78899c86d5354bd45d695d1fa467`. + +## Emitted chunk and closure + +The retained build emits +`out/main/chunks/account-runtime-coordination-startup-capability-BWSWJ-5l.js` at +10,597 raw bytes with SHA-256 +`ecae1f70ea42ac1fc0c18b07b8c4ede109aebb3435e1635eb70ddda2baa8d3c9`. +Its two direct literal relative edges are: + +- `../index.js` +- `./minimax-cookie-store-nsVHFZ_v.js` + +The `../index.js` edge is the bundler's shared-entry cycle and preserves existing +account/runtime singleton state rather than duplicating it. + +An inclusive Acorn AST walk followed literal relative import, export, +dynamic-import, and `require` edges. The capability closure visited 127 JavaScript +files and validated 681 edges. A separate scan of all 137 emitted-main JavaScript +files validated 729 edges. Every target exists and remains beneath `out/main`; no edge +escapes the emitted directory. + +## Budget arithmetic + +The previous Electron-main raw budget was 3,605,809 bytes. Lowering only that value by +the exact 12,810-byte reduction produces 3,592,999 bytes: + +`3,592,999 - 3,544,763 = 48,236` + +Preload and renderer budgets are unchanged. + +## Validation + +- Fresh A and final B `pnpm run build:electron-vite`: passed. +- Focused capability/owner/source-boundary plus account services, Claude + account/auth/runtime selection/live-PTY, Codex runtime selection, MiniMax + credential/cookie, rate-limit target/synchronization/fetcher/service, terminal + runtime, runtime-service, desktop startup ordering, daemon startup/WSL, PTY startup + barrier, SSH rearm, remote runtime, folder-workspace path, renderer shutdown, and + relaunch coverage: 56 files / 644 tests passed. +- `pnpm run typecheck:node`: passed. +- Targeted `oxlint --deny-warnings`: passed. +- Targeted `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with no new bypasses. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +## Platform limitation + +The source, unit/integration tests, and generated-closure validation retain the existing +Windows path/safeStorage behavior, macOS lifecycle, Linux headless behavior, WSL, +SSH/remote, and folder-workspace gates. Packaged-ASAR launch verification was not run +on macOS, Linux, and Windows and remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-account-services-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-account-services-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..1abfa896abd --- /dev/null +++ b/tools/benchmarks/results/phase1-account-services-startup-boundary-2026-07-29.md @@ -0,0 +1,200 @@ +# Phase 1 account-services startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move the five related account-service constructors behind one aggregate startup + capability while preserving construction side effects, Codex migration policy, identity, and + all downstream rate-limit/runtime/IPC/window/PTY/automation/quit wiring. + +## Result + +`src/main/index.ts` now type-imports `RateLimitService`, `CodexRuntimeHomeService`, +`CodexAccountService`, `ClaudeRuntimeAuthService`, and `ClaudeAccountService`. At the original +construction point it awaits exactly one +`./startup/account-services-startup-capability` import and synchronously calls one aggregate +factory. The factory returns the same five live instances, which `index.ts` assigns to the +existing globals before any post-construction wiring. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,728,795 | 7,331,492 | -397,303 | 1,615,903 | 1,539,032 | -76,871 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison matched all 786 emitted files outside `out/main`, covering the +complete preload and renderer output. + +## Importer, constructor, and interface evidence + +Before the edit, production-source searches excluding tests found `src/main/index.ts` as the +sole direct constructor site for all five classes and the only production value importer needed +for startup construction. Runtime, core IPC, provider-specific IPC, auth preservation, account +target sync, and the account services themselves consume these dependencies through type-only +imports. + +After the edit, `src/main/startup/account-services-startup-capability.ts` is the sole production +constructor site and value importer for all five classes. `index.ts` has one aggregate dynamic +import, one factory call, and no direct constructor. This is intentionally one aggregate +capability rather than five mechanical dynamic imports. + +The factory contract is: + +- the original `Store` instance; +- one narrow `configureCodexRuntimeHome(runtimeHome)` composition callback; +- a callback result containing only the `CodexAccountService` lifecycle and + `afterCodexAccountCreated` hook needed to preserve startup sequencing; +- a return value containing the five named live services. + +It does not expose raw constructor argument bags. The factory remains synchronous after the +awaited module import, so constructor execution, post-Codex scheduling, and global assignment +complete in one JavaScript turn before the asynchronous Claude runtime-auth sync can resume. + +The exact internal order is covered by a focused test: + +1. `RateLimitService` +2. `CodexRuntimeHomeService` +3. `configureCodexRuntimeHome` +4. `CodexAccountService` with the callback-provided lifecycle +5. callback-provided `afterCodexAccountCreated` +6. `ClaudeRuntimeAuthService` +7. `ClaudeAccountService` + +The same `Store` object is passed by identity to both runtime-auth services and both account +services. The same `RateLimitService` is passed to both account services, the same +`CodexRuntimeHomeService` is passed to the Codex account service, and the same +`ClaudeRuntimeAuthService` is passed to the Claude account service. The returned values are +those exact constructed objects. + +## Preserved constructor side effects and Codex policy + +No service implementation or constructor changed: + +- `RateLimitService` retains its initial provider state, Grok auth snapshot, fetch generations, + polling state, abort/fetch queues, caches, resolvers, and listeners. +- `CodexRuntimeHomeService` still performs legacy shared-auth, managed-state, and active-home + migrations; initializes last-synced state; and synchronously attempts the current-selection + sync before configuration continues. +- `CodexAccountService` still hydrates the reset-credit ledger and synchronizes canonical config + to managed homes during construction. +- `ClaudeRuntimeAuthService` still initializes last-synced state and starts its asynchronous safe + current-selection sync at the same relative point. +- `ClaudeAccountService` still receives the already-created shared rate-limit and runtime-auth + services without adding constructor work. + +The explicit configuration callback remains in `index.ts`, where policy ownership belonged: + +- it assigns the live runtime-home service before installing callbacks; +- it retains the real-home lane gate through `isRealHomeCodexHookLaneUsable`; +- it retains system-home hook-sweep suppression with the same global service, persisted + hook-setting, downgrade, opt-out, and incapable-lane behavior; +- it creates the same `createCodexSessionMigrationScheduler` with the same real-home eligibility, + `isQuitting` fence, system-home override, backfill, and index-heal functions; +- it returns `requestRun` as `onHostSystemDefaultSelected`; +- it returns `scheduleInitialRun` as the post-Codex-account hook, so initial scheduling remains + after Codex account construction and before Claude runtime-auth construction. + +The scheduler remains owned by the composition callback. Its 15-second deferred initial run, +single active task, stop observation, rerun request, backfill-before-index-heal order, error +fallback, and quit/eligibility fencing are unchanged. + +## Preserved downstream identity and lifecycle + +All post-construction wiring remains in `index.ts` and occurs after the five global assignments: + +- Codex home preparation for rate-limit fetches and initial host/WSL targets; +- Claude auth preparation and initial host/WSL targets; +- settings-driven account runtime-target synchronization; +- live Claude statusline rate-limit ingest and drained-live-PTY refresh; +- OpenCode Go, MiniMax, Gemini CLI OAuth, and proxy resolvers; +- inactive managed Claude and Codex account resolvers, including WSL metadata; +- `runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })`; +- commit-message and terminal launch preparation; +- core account, config-sync, MiniMax, and rate-limit IPC handlers; +- main-window rate-limit attachment and deferred polling start. + +The existing `openMainWindow` guards still require all five services before core handlers or +renderer readiness. `registerCoreHandlers` receives the same global account/rate-limit +singletons, and Codex config sync still resolves `codexAccounts.runtimeHomeService`, preserving +identity. Runtime account list/select/remove/reset-credit RPC methods use the same objects +installed through `setAccountServices`. + +Codex PTY, commit-message, AI-vault discovery/resume, trust-grant, managed-hook, and session +preparation continue to use the same runtime-home global. Claude PTY and rate-limit preparation +continue to use the same runtime-auth global. Relaunch and update preparation still calls +`preserveAgentAuthBeforeRestart` with both globals, including host Codex, WSL Codex, and +asynchronous Claude sync within the existing bounded timeout. + +No local, WSL, SSH, remote-runtime, or folder-workspace routing changed. SSH/remote targets still +delegate to their existing provider/runtime paths; host and WSL account selection normalization, +managed-home metadata, folder-workspace identities, and Git behavior are untouched. The +`before-quit` handler still calls `rateLimits?.stop()` at the same point, preserving timer, +listener, and in-flight fetch cleanup. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/account-services-startup-capability-BYfjIGox.js` (393,058 raw / 76,556 gzip +bytes). `out/main/index.js` loads it through +`./chunks/account-services-startup-capability-BYfjIGox.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./win32-utils-DtAFUr2N.js` +- `./codex-app-server-client-BaImxMIx.js` +- `./wsl-paths-B-s6uTn_.js` +- `./hook-service-Bs9D1qTr.js` +- `./pty-path-safety-P8gLt_xB.js` +- `./codex-home-paths-BljbrLeG.js` +- `./wsl-login-shell-command-Jp1YK-Ls.js` +- `./wsl-By_HVg-G.js` +- `../index.js` +- `./codex-usage-store-startup-capability-sx-AiLqS.js` + +These paths match packaged-relative CommonJS resolution. + +## Budget + +The prior `electron-main` raw budget was 7,777,031 bytes. Lowering it by the exact measured +397,303-byte improvement produces a new budget of 7,379,728 bytes and leaves exactly 48,236 +bytes (0.658%) of headroom over the 7,331,492-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,970 main modules; the post-edit build transformed 1,971. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused aggregate capability/boundary, rate-limit service and fetch targets, Codex + runtime-home/account/session migration, Claude runtime-auth/account/live-PTY, runtime accounts, + full runtime, core/rate-limit IPC, auth preservation, PTY launch, commit environment, + desktop/serve startup, and runtime startup-failure suite: passed, 27 files with 1,834 tests + passed and one skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, report, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,331,492 actual versus 7,379,728 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-agent-awake-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-agent-awake-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..9c740027b76 --- /dev/null +++ b/tools/benchmarks/results/phase1-agent-awake-startup-boundary-2026-07-29.md @@ -0,0 +1,89 @@ +# Phase 1 agent awake startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move `AgentAwakeService` startup construction and runtime-empty status + initialization out of the eager Electron main graph without changing readiness or service + lifetime. + +## Result + +`src/main/index.ts` now type-imports `AgentAwakeService`, awaits the dynamic +`./startup/agent-awake-startup-capability` import, and awaits +`createAgentAwakeStartupCapability()` at the original construction site. The factory returns the +live service after `setStatuses([])`, before the persisted enabled setting, hook subscriptions, +IPC registration, and the existing `services-initialized` milestone. Existing settings updates, +status changes, resume recovery, platform assertion failure handling, and teardown continue to +use the same global instance. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,973,894 | 7,959,821 | -14,073 | 1,664,053 | 1,661,668 | -2,385 | + +The other static startup graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Importer and boundary evidence + +Before the edit, a production-source search found `src/main/index.ts` as the sole value importer +of `agent-awake-service`. The only other production importers were type-only: + +- `src/main/ipc/register-core-handlers.ts` +- `src/main/ipc/settings.ts` + +After the edit, `src/main/startup/agent-awake-startup-capability.ts` is the sole production value +importer. `src/main/index.ts` and both IPC consumers are type-only. + +The emitted implementation chunk is +`out/main/chunks/agent-awake-startup-capability-TObNGnOu.js` (14,527 raw / 2,844 gzip bytes). +`out/main/index.js` loads it with +`require("./chunks/agent-awake-startup-capability-TObNGnOu.js")`; the specifier is +packaged-relative, contains no parent traversal, and resolves beside the main entry in the +packaged ASAR. + +The source boundary test rejects a value import or direct construction in `src/main/index.ts`, +requires exactly one awaited capability import and factory call, and requires initialization +before settings, hook subscriptions, and `services-initialized`. The factory test confirms that +the returned live `AgentAwakeService` receives the initial empty status set. + +## Budget + +The `electron-main` raw budget is 8,008,057 bytes. This lowers the prior 8,022,130-byte budget by +the measured 14,073-byte improvement and leaves 48,236 bytes (0.606%) of headroom over the +7,959,821-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production baseline and post-edit `pnpm run build:electron-vite`: passed. The post-edit + build transformed 1,957 main modules and emitted the packaged-relative capability chunk. +- Focused keep-awake, platform, IPC, and startup suite: passed, 13 files and 120 tests. Coverage + included the new capability and boundary tests, `AgentAwakeService`, macOS and Linux + assertions, Windows-safe service behavior, system resume, settings and core IPC, desktop and + serve ordering, startup diagnostics, and runtime RPC startup failure. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the capability, and its two tests: passed + with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,959,821 actual versus 8,008,057 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both builds contained the same two existing CSS optimizer warnings and no new warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The service contract still avoids any Windows global power-plan mutation; macOS and Linux +assertion behavior remains covered by focused tests. The build proves packaged-relative chunk +generation on this macOS worktree, but this tranche did not run packaged ASAR launch smokes on +macOS, Linux, or Windows. diff --git a/tools/benchmarks/results/phase1-agent-browser-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-agent-browser-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..a93e2b95dff --- /dev/null +++ b/tools/benchmarks/results/phase1-agent-browser-startup-boundary-2026-07-29.md @@ -0,0 +1,84 @@ +# Phase 1 agent browser startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move `AgentBrowserBridge` construction out of the eager Electron main graph without + changing browser ownership, runtime contracts, or initialization timing. + +## Result + +`src/main/index.ts` now awaits one dynamic import of +`./startup/agent-browser-startup-capability`, then attaches one bridge to +`OrcaRuntimeService` before the existing `services-initialized` milestone. The factory receives +the existing `browserManager` and preserves `onTabsChanged` forwarding to +`notifyMobileSessionTabsChanged`, so desktop, serve, local, remote, folder-workspace, and SSH +runtime paths continue to observe a fully attached bridge without an initializing state. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 8,065,000 | 7,973,894 | -91,106 | 1,682,165 | 1,664,053 | -18,112 | + +The other static startup graphs were unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Boundary evidence + +The generated implementation chunk is +`out/main/chunks/agent-browser-startup-capability-w5Hfh7H7.js` (92,012 raw / 18,880 gzip +bytes). The generated main entry loads it with +`require("./chunks/agent-browser-startup-capability-w5Hfh7H7.js")`; the specifier is relative, +contains no parent traversal, and resolves beside `out/main/index.js` inside the packaged ASAR. +The bridge class and its direct construction are present only in that chunk, not the eager main +entry. + +`src/main/startup/agent-browser-startup-capability.ts` is the only production value importer of +`agent-browser-bridge`. Existing production consumers remain type-only: + +- `src/main/runtime/orca-runtime.ts` +- `src/main/runtime/orca-runtime-browser.ts` +- `src/main/ipc/browser.ts` + +The source contract rejects a static `agent-browser-bridge` import or direct construction in +`src/main/index.ts`, requires exactly one awaited capability import, and rejects attachment after +`services-initialized`. The focused factory test confirms one attached bridge and the unchanged +mobile session-tab callback. + +## Budget + +The `electron-main` raw budget is 8,022,130 bytes. This lowers the prior 8,113,236-byte budget by +the exact 91,106-byte improvement and leaves 48,236 bytes (0.605%) of headroom over the +7,973,894-byte entry. No other budget changed. + +## Validation + +- Focused browser/runtime/startup suite: passed, 12 files and 196 tests, including the bridge, + browser IPC/RPC, browser session startup, desktop/serve ordering, runtime startup failure, and + retained emulator boundary. +- `pnpm run build:electron-vite`: passed with 1,956 main modules transformed, both capability + chunks emitted, and only the two existing warnings below. +- `pnpm run check:electron-bundle-budgets`: passed at 7,973,894 actual versus 8,022,130 budgeted + main bytes. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the factory, and its two tests: passed with + no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those four files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `git diff --check`: passed with no whitespace errors. + +## Warning inventory and limitation + +The production build retained two existing CSS optimizer warnings and introduced no new warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The build proves packaged-relative chunk generation on this macOS worktree, but this tranche did +not run a packaged ASAR launch smoke test on macOS, Linux, or Windows. Those cross-platform +packaged smokes remain the residual verification risk for dynamic chunk loading. diff --git a/tools/benchmarks/results/phase1-agent-hook-runtime-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-agent-hook-runtime-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..5075435c7e0 --- /dev/null +++ b/tools/benchmarks/results/phase1-agent-hook-runtime-startup-boundary-2026-07-29.md @@ -0,0 +1,198 @@ +# Phase 1 agent-hook runtime startup boundary — 2026-07-29 + +**Scope:** Move the remaining eager agent-hook server, WSL relay, managed-installer, +provider-session, migration, and first-work rename identities behind one aggregate app-ready +capability without changing their state ownership or lifecycle ordering. + +## Result + +`src/main/index.ts` no longer value-imports any of the eleven targeted agent-hook values. It +performs one dynamic import of `./startup/agent-hook-runtime-startup-capability`, installs the +exact returned object in a typed owner, and uses those identities through existing startup, +runtime, window, managed-hook, and shutdown call sites. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,697,673 | 3,636,591 | -61,082 | 785,736 | 769,478 | -16,258 | + +The preload and every renderer static graph were byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 89,303 bytes and had SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; +their direct diff was empty. + +## Production importer audit + +Before this tranche, `index.ts` directly imported: + +- `runManagedHookInstallers`; +- `isAgentStatusHooksEnabled`; +- `MANAGED_AGENT_HOOK_INSTALLERS`; +- `removeManagedAgentHooks`; +- `agentHookServer`; +- `createHookProviderSessionInvalidator`; +- `wslHookRelayManager`; +- `maybeAutoRenameBranchOnFirstWork`; +- `rememberBranchRenameFailureOutput`; +- `renameWorktreeFolderOnFirstWork`; and +- `setMigrationUnsupportedPtyListener`. + +The complete production audit found these other importers: + +- `persistence.ts` consumes the exact server and migration-state modules for persisted pane aliases, + authority transfer, and persistence listeners. Store construction is behind the retained Store + startup capability. +- `ipc/pty.ts` consumes the same server, WSL relay manager, enablement predicate, and migration + state for local/WSL PTY environments, relay provisioning, alias cleanup, and spawn metadata. It + is behind the retained terminal-runtime capability. +- agent-hooks, agent-pane-authority, settings, and worktree IPC consume the same server, migration, + enablement, and rename-failure modules behind the retained core-IPC registry. +- `ssh/ssh-relay-session.ts` consumes the same server and enablement predicate for remote ingestion, + connection cleanup, and SSH status gating. +- `runtime/orca-runtime.ts` consumes the same managed-hook control module for live setting changes + behind the retained runtime-service capability. +- `wsl-hook-relay-deps.ts` closes over the same server for coordinates, endpoint paths, and remote + relay ingestion. + +The terminal-runtime and main-window capabilities may evaluate some shared modules before this +aggregate loads, but module caching ensures the aggregate returns those exact existing singleton +objects. No replacement server, relay manager, status map, listener registry, timer, or installer +array is created. + +## Lifecycle and order audit + +There is no supported pre-app-ready hook event. Before readiness, `index.ts` only defines callback +functions; Store hydration, terminal startup, runtime construction, window creation, PTY +registration, SSH relay construction, and managed-hook reconciliation all occur inside or after +`app.whenReady`. + +The retained order is: + +1. Browser, main-window, terminal-runtime, updater-runtime, and desktop-shell capabilities load + and install. +2. The agent-hook aggregate loads once and its exact object is installed in the owner. +3. Browser setup and certificate handling continue, followed by CLI/WSL reconciliation. +4. Store construction and hydration run with the same server singleton already available for pane + alias restoration and migration state. +5. Status and provider-session subscriptions are attached to the same server; their snapshots, + provider-session invalidation, AgentAwake updates, and mobile publication order are unchanged. +6. Runtime construction receives the same terminal-status ingestion callback, live status and + provider-session snapshot callbacks, and gated PTY environment builder. +7. Plugin enriched-status subscription and managed-hook install/remove reconciliation retain their + original positions. +8. Terminal startup invokes the same first-window service aggregate. The hook server still starts + only when enabled, with the original packaged/dev environment, canonical Electron userData + argument, dev endpoint namespace, milestone logging, and fail-open error callback. +9. Desktop loads core IPC before opening its window while headless serve waits for its existing + PTY/WSL barriers; both modes share the same live hook server and relay identities. + +The owner throws if a required consumer runs before installation. `will-quit` uses the optional +owner read because Electron can theoretically quit before readiness. Before installation neither +singleton could have started, so the no-op is safe; after installation it calls the exact +`agentHookServer.stop` and `wslHookRelayManager.disposeAll` methods at the original positions. + +## Identity and behavior preservation + +The capability returns original exports without wrapping them. This preserves: + +- status, provider-session, enriched-status, pane-authority, prompt-interaction, replay, and + migration-unsupported state; +- live snapshots, pane rows, terminal ingestion, SSH remote ingestion, and connection cleanup; +- PTY hook coordinates, current endpoint-file lookup, WSL guest endpoint/overlay lookup, relay + install recovery, and disposal; +- first-work branch rename gating, upstream probes, unique-name resolution, failure-output + storage, and notification order; +- folder-workspace title/folder rename behavior, `moveWorktree` callback identity, workspace + identity migration, and runtime notifications; +- managed installer array identity, enablement checks, install/remove policy, telemetry, and + error timing; +- Codex real-home and managed-home hook checks before launch/resume; +- same-window listener detach/replay during close and macOS dock recreation; and +- desktop, headless serve, SSH, remote, WSL, and local PTY behavior. + +No hook protocol, IPC/RPC channel, Git command, resource owner, telemetry event, or error policy +changed. First-work Git execution continues through the existing host-aware providers, so folder +workspaces, SSH/remote, WSL, Git providers, and the Git 2.25 baseline remain unchanged. + +## Emitted chunks and dependency closure + +The retained build emits +`out/main/chunks/agent-hook-runtime-startup-capability-Sk-DFQKy.js` at 15,834 raw / 4,536 gzip +bytes with SHA-256 +`769ad9945eb09b6c13a1ca62c327a44ea892f84c2f9b72fdd4b6b42ed9128c7e`. +Its 19 static relative dependencies are: + +- `./chunk-BTjIgr6M.js` +- `./win32-utils-DtAFUr2N.js` +- `./fs-utils-D5115c5m.js` +- `./tui-agent-config-DMkBaphp.js` +- `./execution-host-DX_Sa8Eh.js` +- `./wsl-BuuvEEky.js` +- `./hook-service-BPcIgznJ.js` +- `./agent-session-resume-C5EO_KoU.js` +- `./stable-pane-id-CMF3MDnN.js` +- `./grok-session-paths-D98Yfwjz.js` +- `./codex-app-server-client-C9VhVlb_.js` +- `./managed-agent-hook-controls-B0Ra2eJ_.js` +- `./codex-home-paths-C8xszHr4.js` +- `./worktree-id-zL7_Bkk5.js` +- `../index.js` +- `./macos-tailscale-dns-diagnostic-WXXhbYHn.js` +- `./agent-failure-output-CQ28WguA.js` +- `./branch-rename-failure-output-V43lcPT1.js` +- `./commit-message-agent-environment-DffRi7dm.js` + +The `../index.js` edge is the bundler's expected shared-entry cycle and preserves shared +composition-root callbacks rather than duplicating state. + +An Acorn AST walk of the capability closure visited 119 JavaScript files and validated 639 +literal relative import, export, dynamic-import, and require edges. A separate full `out/main` +scan checked all 129 JavaScript files and 687 relative edges. Every resolved target exists beneath +`out/main`; no edge escapes the emitted directory. + +The A entry SHA-256 was +`12659e2bc9a122e5d9e62a34e05aa8edab6e5c51ef2606f63400c5d5995b6aed`; the retained B entry +SHA-256 is `f215df1d66b8eea478f2910eb8ec0b2c697a218397c2384583c0f982569a5839`. + +## Budget + +The prior Electron-main raw budget was 3,745,909 bytes. Lowering only that budget by the exact +61,082-byte reduction produces 3,684,827 bytes and leaves exactly 48,236 bytes of headroom over +the 3,636,591-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and final B `pnpm run build:electron-vite`: passed; main transforms changed + from 1,989 to 1,991, preload remained 17 modules, and renderer remained 9,181 modules. +- Focused capability, owner, source-boundary, server, Claude statusline, Codex subagent, Grok + discovery, provider-session, pane-authority, interactive-question, first-work branch/folder, + failure-output, migration, managed-hook installer/runtime/lock/filesystem/stdin/timeout, WSL + guest/relay/recovery/sentinel, hook IPC, PTY barrier/management, persistence hydration, SSH relay + hook/session, runtime mobile snapshot, full runtime, first-window startup, desktop/serve + activation, and shutdown coverage: 40 files / 1,868 passed and 2 skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,636,591 actual versus 3,684,827 budgeted + Electron-main bytes. +- `git diff --check`: passed with no whitespace errors. + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +## Remaining limitation + +The production build and closure scans validate emitted relative dependency resolution on this +macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and Windows; +cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-automation-service-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-automation-service-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..61298d130d3 --- /dev/null +++ b/tools/benchmarks/results/phase1-automation-service-startup-boundary-2026-07-29.md @@ -0,0 +1,136 @@ +# Phase 1 automation service startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `AutomationService` startup construction out of the eager Electron main + graph while preserving the complete desktop and headless automation lifecycle at the existing + startup point. + +## Result + +`src/main/index.ts` now type-imports `AutomationService`, awaits the dynamic +`./startup/automation-service-startup-capability` import, and awaits +`createAutomationServiceStartupCapability(store, options)` at the original construction site. +The factory calls only `new AutomationService(store, options)` and returns that same live +instance; `src/main/automations/service.ts` was not changed. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,934,074 | 7,909,325 | -24,749 | 1,656,699 | 1,652,751 | -3,948 | + +The other static startup graphs were byte-for-byte unchanged. A full SHA-256 manifest comparison +of every file under `out/preload` and `out/renderer` also matched: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Importer evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of `src/main/automations/service.ts`. All other +production runtime and IPC consumers were already type-only: + +- `src/main/runtime/orca-runtime.ts` +- `src/main/ipc/register-core-handlers.ts` +- `src/main/ipc/automations.ts` + +After the edit, `src/main/startup/automation-service-startup-capability.ts` is the sole production +value importer and direct constructor. `src/main/index.ts` and all three runtime/IPC consumers +remain type-only. + +The focused boundary test rejects an eager service value import or direct construction in +`src/main/index.ts`, requires exactly one awaited capability import and factory call, and fixes +construction and `runtimeService.setAutomationService` before `services-initialized`. The factory +test verifies that the original store and options object reach the constructor by identity and +that the constructed live instance is returned. + +## Preserved behavior and ordering + +The dynamic import and factory call replace the constructor expression at its exact former +location. The complete options object and `headlessDispatcher` closure remain in `src/main/index.ts` +without being moved, simplified, or redesigned: + +- `claudeUsage` and `codexUsage` are still injected. +- `allowRemoteHostScheduling` remains `isServeMode`. +- `headlessDispatcher` remains present only for serve mode. +- `new_per_run` still calls `runtimeService.createManagedWorktree` with + `buildHeadlessAutomationWorktreeCreateArgs`, routes through `target.repo`, uses the returned + startup terminal and worktree metadata, and preserves the missing-terminal error. +- Existing-workspace dispatch still requires `automation.workspaceId`, calls + `runtimeService.launchAgentTerminal`, and obtains the display name through + `showManagedWorktree`. +- Completion still waits for `tui-idle`, reads the terminal with the 2,000-line limit, appends + the joined tail to `createHeadlessAutomationOutputSnapshotBuffer`, maps a satisfied wait to + `completed`, and maps blocked or incomplete waits to `dispatch_failed` with the same error + text. + +The awaited factory completes before `runtimeService.setAutomationService(automations)` and +before `services-initialized`, so readiness and initializing contracts are unchanged. The same +returned global instance keeps every later lifecycle: + +- Desktop startup registers core handlers, then calls `setWebContents(window.webContents)`, then + `start()`. +- Closing a desktop window still calls `setWebContents(null)`. +- `automations:rendererReady` still calls `setRendererReady()` on the service supplied to the + unchanged IPC registration path. +- Headless serve still starts the service after runtime RPC startup and before + `printServeReady(...)` and the serve return. +- The single `automations?.stop()` remains in the committed `will-quit` handler. + +No readiness milestone, initialization state, core-handler attachment, runtime wiring, desktop +window ordering, serve-ready publication, or quit commitment moved. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/automation-service-startup-capability-E2N0odgG.js` (18,172 raw / 4,647 gzip +bytes). `out/main/index.js` loads it with +`require("./chunks/automation-service-startup-capability-E2N0odgG.js")`. + +The entry specifier is relative, contains no parent traversal, and resolves to the emitted file +under `out/main/chunks`. Every relative `require` in the capability chunk was resolved and +confirmed to exist under `out/main`; this includes its emitted shared chunks and the expected +`../index.js` reference back to the already-loaded main entry. The resulting paths match +packaged-ASAR-relative lookup. + +## Budget + +The `electron-main` raw budget is 7,957,561 bytes. This lowers the prior 7,982,310-byte budget by +the exact measured 24,749-byte improvement and leaves 48,236 bytes (0.610%) of headroom over the +7,909,325-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,959 main modules; the post-edit build transformed 1,960 and emitted the deferred capability + chunk. +- Focused automation, IPC, runtime, and startup suite: passed, 15 files and 89 tests. Coverage + included the service and precheck behavior, precheck runner, target resolution, headless + workspace arguments and dispatcher source boundary, core-handler registration, runtime + automation wiring and RPC methods, the new factory and source boundary, desktop/serve startup + ordering and activation, and runtime RPC startup failure. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the capability, and its two tests: passed + with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,909,325 actual versus 7,957,561 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The build and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-browser-kernel-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-browser-kernel-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..07dafa3f125 --- /dev/null +++ b/tools/benchmarks/results/phase1-browser-kernel-startup-boundary-2026-07-29.md @@ -0,0 +1,180 @@ +# Phase 1 browser-kernel startup boundary — 2026-07-29 + +**Scope:** Move the concrete browser manager, certificate trust controller, and browser-session +startup graph behind one app-ready capability while preserving synchronous window creation, +singleton identity, browser security policy, desktop/headless readiness, and shutdown cleanup. + +## Result + +`src/main/index.ts` now type-imports `BrowserManager` and awaits one +`./startup/browser-kernel-startup-capability` immediately after the `app-ready` milestone. The +capability returns the existing manager, trust controller, and session initializer identities and +links synchronous window consumers to the same manager/session registry through a narrow typed +slot. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,565,102 | 4,396,073 | -169,029 | 972,143 | 934,292 | -37,851 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 87,731 bytes and had SHA-256 +`68ba944c45d80c14c538180894eade74a3897e120fa0bf6a89fbb60500a3ab01`; a direct diff produced no +output. + +## Importer and readiness audit + +Before the edit: + +- `index.ts` eagerly value-imported `browserCertificateTrustController` and `browserManager` from + `browser-manager.ts`, plus `initializeBrowserSessionsForApp` from + `browser-session-startup.ts`. +- `createMainWindow.ts` eagerly imported both `browserManager` and `browserSessionRegistry`. +- `attach-main-window-services.ts` eagerly imported `browserManager`. +- `browser-session-registry.ts` imports the same manager singleton to install download, + permission, and certificate policies. +- Browser IPC imports the manager, trust controller, and registry behind the already-deferred + aggregate core-IPC boundary. +- `orca-runtime-browser.ts` imports the same identities behind the already-deferred aggregate + runtime-service capability. +- Agent-browser and offscreen startup capabilities type-import `BrowserManager`; their concrete + implementations remain dynamically attached later. + +No browser value is consumed before `app.whenReady`: + +- `createMainWindow` and `attachMainWindowServices` are defined eagerly but are not invoked before + the desktop startup branch opens a window. +- Core browser IPC is loaded only on the desktop path after service initialization. +- Runtime browser commands are constructed only after the deferred runtime-service load. +- Headless offscreen attachment occurs inside the existing serve/display gates. +- The module-level quit handler only clears a listener that is installed after browser-kernel + initialization. + +After the edit: + +- `index.ts` has one type-only browser-manager import, no browser-session-startup import, and one + dynamic aggregate capability. +- Both eager window modules import only `browser-kernel-window-dependencies.ts`, whose runtime + state is a manager reference plus one partition predicate and has no concrete browser imports. +- The capability is the explicit value seam for the manager, controller, session registry, and + initializer. + +## Capability and singleton linkage + +`createBrowserKernelStartupCapability` imports the existing module singletons; it does not +construct replacements. `browser-manager.ts` still constructs exactly one `BrowserManager`, then +one `BrowserCertificateTrustController` whose callbacks resolve guest state through that manager, +and finally installs that controller back onto the same manager. + +The capability configures synchronous window consumers with: + +- that exact `BrowserManager`; and +- a closure over the exact `browserSessionRegistry.isAllowedPartition` method. + +`createMainWindow` resolves the linked dependencies at function entry. It still installs the +dictation predicate, validates every webview partition against the same active-profile registry, +attaches guest policies to the same manager, and clears the predicate on close. +`attachMainWindowServices` resolves the same manager and still calls `unregisterAll` when its +window closes. A missing startup link fails synchronously before window wiring instead of allowing +a partially secured window. + +Focused tests prove returned identity, manager/controller linkage, registry delegation, the one +dynamic import/factory, and removal of concrete browser values from the two eager window modules. + +## Startup, session, and lifecycle order + +The retained order is: + +1. `app.whenReady` resolves and records `app-ready`. +2. The browser-kernel capability loads and links synchronous window consumers. +3. The existing main-thread watchdog/hang marker work runs. +4. The `certificate-error` handler is registered with the same `event`, `webContents`, `url`, + `error`, `certificate`, `callback`, and `isMainFrame` arguments. +5. `app.setName`, CLI/WSL reconciliation, Store/profile startup, app icon, and proxy application + continue unchanged. +6. `initializeBrowserSessionsForApp` receives the same active profile ID and profile directory at + the original point after proxy application and before system-resume registration. +7. The same session registry applies pending cookie import before the first partition access, + hydrates persisted profiles, and remains guarded by its existing one-time initialization flag. +8. The same manager receives the keybinding settings resolver, mobile guest-state listener, + agent-browser bridge, and serve-only offscreen backend. +9. Headless graph sync precedes RPC start and serve readiness; desktop window creation and RPC + startup retain their parallel order. +10. `will-quit` clears the guest-state listener from the exact retained manager, then existing + agent-browser/offscreen teardown continues unchanged. + +The registry singleton now evaluates after Electron readiness but still before `app.setName` and +before any session/window access. Its fallback user-data lookup therefore retains the same +canonical Electron path, while active-profile configuration and cookie replay remain at their +original later point. + +Certificate admission, guest registration, permissions, downloads, partitions, popup/navigation +policy, settings, SSH/remote and folder-workspace routing, and macOS/Linux/Windows gates were not +changed. + +## Emitted chunks and dependency closure + +The retained build emits: + +- `out/main/chunks/browser-kernel-startup-capability-C4bJ_B8V.js`: 1,267 raw / 454 gzip bytes, + SHA-256 `7e9d2f0e58fb47e44b8938e7c64497abbeb528c12509a8fc2ccdf45848d0e452`; +- `out/main/chunks/browser-session-registry-HfuHVwQ8.js`: 167,008 raw / 36,977 gzip bytes, + SHA-256 `983e503819f0ff956d55653e0bfe59657498f90b8629b29e2ef8f2cad27ef7d2`. + +The capability's four direct static relative dependencies are: + +- `./tui-agent-config-DMkBaphp.js` +- `./keybindings-dTghwn23.js` +- `../index.js` +- `./browser-session-registry-HfuHVwQ8.js` + +The `../index.js` edge is the bundler's expected shared-module cycle; the capability remains +dynamically entered from `index.js`. + +An AST-based walk from the capability visited 90 JavaScript files and validated 415 relative +edges. A separate complete emitted-main scan checked all 100 JavaScript files and all 460 literal +relative `require`, `import`, and export references. Every target exists and resolves beneath +`out/main`; no specifier escapes through parent traversal. + +## Budget + +The prior `electron-main` raw budget was 4,613,338 bytes. Lowering only that budget by the exact +169,029-byte reduction produces 4,444,309 bytes and leaves exactly 48,236 bytes of headroom over +the 4,396,073-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,978 main modules and the retained build transformed 1,980; both transformed 17 preload + modules and 9,181 renderer modules. +- Complete `src/main/browser` plus create/attach window services, browser IPC, runtime browser, + runtime browser RPC methods, agent-browser/offscreen boundaries, desktop startup ordering, + serve activation/readiness/stdout, and runtime RPC startup-failure coverage: 45 files / 748 + tests passed. +- Focused capability, source-boundary, and window consumer subset: 4 files / 116 tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,396,073 actual versus 4,444,309 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-claude-usage-store-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-claude-usage-store-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..6e3d880303f --- /dev/null +++ b/tools/benchmarks/results/phase1-claude-usage-store-startup-boundary-2026-07-29.md @@ -0,0 +1,145 @@ +# Phase 1 ClaudeUsageStore startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `ClaudeUsageStore` construction out of the eager Electron main graph while + preserving synchronous Claude usage-path capture, store identity, scanning, IPC, and automation + attribution. + +## Result + +`src/main/index.ts` now type-imports `ClaudeUsageStore`, eagerly imports only +`initClaudeUsagePath` from the concrete +`src/main/claude-usage/claude-usage-file-path.ts` module, and awaits the dynamic +`./startup/claude-usage-store-startup-capability` import at the original construction site. The +factory calls only `new ClaudeUsageStore(store)` with the original `Store` object and returns that +same live instance. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,891,025 | 7,852,413 | -38,612 | 1,649,850 | 1,641,212 | -8,638 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison also matched all 786 emitted files under `out/preload` and +`out/renderer`. + +## Importer and constructor evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of `src/main/claude-usage/store.ts`. Every other +production consumer was type-only: + +- `src/main/ipc/claude-usage.ts` +- `src/main/ipc/register-core-handlers.ts` +- `src/main/automations/service.ts` +- `src/main/automations/run-usage-collection.ts` + +After the edit, `src/main/startup/claude-usage-store-startup-capability.ts` is the sole production +value importer and direct constructor. `src/main/index.ts` and every IPC and automation consumer +remain type-only. `store.ts` retains a compatibility re-export of `initClaudeUsagePath`. + +Only path state, synchronous initialization, and the cached fallback getter moved out of +`store.ts`. The class implementation changed only its two path getter call sites. Schema version +5, model pricing and aliases, normalization, atomic temporary-file persistence, scan gating, +five-minute throttling and automation attribution window, cross-file ownership deduplication, +range/scope queries, and public APIs are unchanged. + +## Preserved path timing, ordering, and identity + +`initClaudeUsagePath()` remains a direct synchronous call in the existing single-instance-lock +block: + +1. It retains its exact sequence after `initDataPath()`, session-parse-cache and profile-path + initialization, and `initStatsPath()`, before the Codex and OpenCode usage paths. +2. It still captures `app.getPath('userData')` before `app.whenReady()` and + `app.setName(devInstanceIdentity.appName)`. +3. It still resolves exactly `/orca-claude-usage.json` with `path.join`. +4. The fallback getter still captures once on first access if startup initialization was skipped. + +The cross-platform path-state test changes the mocked late Electron user-data path after +initialization and proves reads stay on the early captured path. It also proves fallback capture +is cached. Both expectations use `path.join`, including on Windows. + +The awaited capability import and factory replace `new ClaudeUsageStore(store)` at its exact +former services point: after StatsCollector, before Codex and OpenCode usage stores, before every +consumer, and before the `services-initialized` milestone. Construction therefore still +finishes before AutomationService creation and before any main-window IPC registration or +readiness exposure. + +The same singleton remains protected by the existing `openMainWindow` initialization guard, +passed unchanged to `registerCoreHandlers`, and forwarded unchanged to +`registerClaudeUsageHandlers`. The scan-state, enablement, refresh, snapshot, summary, daily, +breakdown, and recent-session IPC channels and arguments did not change. + +The same singleton is also injected into AutomationService in the same options position before +`codexUsage`. Local completed Claude runs still call `getAutomationRunUsage` with the run +workspace, terminal session, start time, and collection time. The untouched run-usage collector +still returns `remote_usage_unavailable` for SSH automation before consulting local usage logs, +and it retains the existing unsupported-provider and unavailable-store mappings. + +The store still receives the original persistence `Store` object by identity and still uses +`loadKnownUsageWorktreesByRepo(this.store, repos)`. The usage metadata and scanner modules are +untouched, preserving folder-workspace filesystem IDs, git-worktree metadata, local transcript +discovery and attribution, WSL/SSH/remote eligibility behavior, canonical path ordering, +incremental file reuse, and fork-copy deduplication. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/claude-usage-store-startup-capability-Dd7yRYUX.js` (39,174 raw / 8,836 gzip +bytes). `out/main/index.js` loads it through +`./chunks/claude-usage-store-startup-capability-Dd7yRYUX.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./worktree-id-B3lEXLSJ.js` +- `../index.js` + +These paths match packaged-relative module resolution. + +## Budget + +The prior `electron-main` raw budget was 7,939,261 bytes. Lowering it by the exact measured +38,612-byte improvement produces a new budget of 7,900,649 bytes and leaves 48,236 bytes +(0.614%) of headroom over the 7,852,413-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,964 main modules; the post-edit build transformed 1,966. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused Claude path, store, pricing, automation attribution, scanner, large-directory, + incremental-scan, worktree metadata and canonicalization, Claude/core IPC identity, + AutomationService and its startup boundary, desktop/serve startup, and runtime startup-failure + suite: passed, 17 files and 96 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,852,413 actual versus 7,900,649 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-cli-wsl-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-cli-wsl-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..2dabf95487b --- /dev/null +++ b/tools/benchmarks/results/phase1-cli-wsl-startup-boundary-2026-07-29.md @@ -0,0 +1,163 @@ +# Phase 1 CLI/WSL startup boundary — 2026-07-29 + +**Scope:** Remove the CLI installer, packaged-Linux bare dispatcher, and managed WSL +reconciliation implementation from the eager Electron-main graph while preserving the original +platform gates, reconciliation barrier, serve behavior, and CLI ownership rules. + +## Result + +`src/main/index.ts` now awaits one `./startup/cli-wsl-startup-capability` module immediately after +`app.setName`. One aggregate capability supplies the original reconciliation function, constructs +the original `CliInstaller` only for the existing serve-only install path, and supplies the +original Linux dispatcher function. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,592,251 | 4,565,102 | -27,149 | 978,843 | 972,143 | -6,700 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 87,731 bytes and had SHA-256 +`68ba944c45d80c14c538180894eade74a3897e120fa0bf6a89fbb60500a3ab01`; a direct diff produced no +output. + +## Production importer audit + +Before the edit, `src/main/index.ts` eagerly value-imported: + +- `CliInstaller` from `cli-installer.ts`; +- `installLinuxBareOrcaDispatcher` from `linux-bare-orca-dispatcher.ts`; +- `reconcileManagedWslCliRegistrations` from + `wsl-cli-registration-reconciliation.ts`. + +The reconciliation module also imports `CliInstaller`, `WslCliInstaller`, the registration +registry, and the serialized registration operation. The dispatcher imports the shared bundled +launcher and AppImage wrapper implementation from the CLI installer graph. + +Other production consumers remain unchanged: + +- `wsl-cli-installer.ts` constructs `CliInstaller` for its default host installer. +- `linux-terminal-orca-cli-shim.ts` consumes the dispatcher's script builder through PTY IPC. +- `ipc/cli.ts` consumes the installer and WSL registration APIs behind the already-deferred + aggregate core-IPC boundary. + +After the edit, `index.ts` has no eager import from any of the three target modules and no direct +`CliInstaller` construction. `cli-wsl-startup-capability.ts` is their single aggregate startup +import seam. It uses `ConstructorParameters` so the concrete installer +options remain checked without exporting a new broad dependency bag. + +## Lifecycle and platform behavior + +The retained startup order is: + +1. Electron readiness, certificate setup, app model ID, and `app.setName` complete. +2. The one aggregate CLI/WSL capability loads. +3. Managed WSL status becomes `pending`. +4. Reconciliation receives the same `app.isPackaged`, canonical user-data path, and app version. +5. The same per-distro failed/repaired messages run; completion sets `settled`, while discovery + failure sets `failed` and retains the original warning. +6. The exact reconciliation promise is passed once to + `createWslCliReconciliationStartupBarrier`. +7. First-window startup and headless serve continue to consume the same bounded two-second + barrier; desktop startup remains independent of unbounded reconciliation. + +The reconciliation implementation still returns immediately without WSL discovery unless the +host is packaged Windows. Candidate selection, host launcher probing, per-distro serialization, +two-worker repair concurrency, ownership observations, error isolation, and registry paths remain +owned by their existing modules. + +In serve mode: + +- CLI auto-install remains after RPC start and signal-handler setup, before readiness publication, + and remains limited to macOS/Linux. +- The capability constructs one live `CliInstaller` with the same options object. Its + `privilegedRunner` still rejects with + `serve CLI auto-install must not request administrator privileges`, preventing a headless admin + prompt. +- Status/result logging and the best-effort error warning are unchanged; Windows remains excluded. +- The bare `orca` dispatcher remains gated on packaged Linux plus `process.resourcesPath`, and + receives the same resources path. +- AppImage targeting, bundled `orca-ide` resolution, foreign-file protection, managed marker, + `~/.local/bin` location, and executable mode remain in the unchanged dispatcher implementation. + +No Store, runtime, folder-workspace, SSH/remote routing, PATH, launcher, Git-provider, or Git 2.25 +behavior changed. Remote serve still performs only the host-platform work admitted by the same +runtime gates. + +## Capability identity and source boundaries + +The capability returns the imported reconciliation and dispatcher functions by identity. +`installServeCli` synchronously constructs the original live `CliInstaller` and invokes +`install()` on that same instance. Focused tests verify the exact option object identities, +constructor/instance identity, one dynamic import/factory, load position, status/error branches, +the shared reconciliation promise, both barrier consumers, serve-only gates, privileged-runner +rejection, dispatcher inputs, and readiness order. + +The existing desktop startup ordering test was updated only to anchor reconciliation at the new +aggregate capability call; all of its barrier and desktop-independence assertions remain intact. + +## Emitted chunk and relative dependency closure + +The B build emits +`out/main/chunks/cli-wsl-startup-capability-DTv3bK3F.js` +(3,610 raw / 1,271 gzip bytes; SHA-256 +`19c0e3402a2b576bf96cfe09ab7df200128f8d4b7c8d56a271d654987ef1e990`). +Its five direct static relative dependencies are: + +- `./win32-utils-DtAFUr2N.js` +- `./fs-utils-D5115c5m.js` +- `./wsl-BkPuEmkc.js` +- `../index.js` +- `./wsl-cli-installer-Crb-rbWF.js` + +The `../index.js` edge is the bundler's expected shared-module cycle; the capability remains +dynamically entered from `index.js`. + +An AST-based walk from the capability visited 87 JavaScript files and validated 401 relative +edges. A separate complete emitted-main scan checked all 97 JavaScript files and all 446 literal +relative `require`, `import`, and export references. Every target exists and resolves beneath +`out/main`; no specifier escapes through parent traversal. + +## Budget + +The prior `electron-main` raw budget was 4,640,487 bytes. Lowering only that budget by the exact +27,149-byte reduction produces 4,613,338 bytes and leaves exactly 48,236 bytes of headroom over +the 4,565,102-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,977 main modules and the retained build transformed 1,978; both transformed 17 preload + modules and 9,181 renderer modules. +- Complete `src/main/cli` plus CLI IPC, WSL installer/registry/reconciliation/serialization, + reconciliation barrier, serve readiness/stdout/update handoff, headless updater/automation, + desktop startup ordering, runtime RPC failure, SSH/remote CLI routing, orchestration CLI, + relaunch, process-parent shutdown, window quit policy, and WSL host coverage: 33 files passed + and 1 skipped; 273 tests passed and 3 skipped. +- Focused capability and source-boundary subset: 2 files / 8 tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,565,102 actual versus 4,613,338 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-codex-launch-session-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-codex-launch-session-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..97ca614af79 --- /dev/null +++ b/tools/benchmarks/results/phase1-codex-launch-session-startup-boundary-2026-07-29.md @@ -0,0 +1,215 @@ +# Phase 1 Codex launch/session startup boundary — 2026-07-29 + +**Scope:** Move the remaining eager Codex launch, hook, trust, resume, migration, +backfill, index-heal, runtime-selection, and home-path values in `src/main/index.ts` +behind one exact-identity app-ready capability. + +## Result + +`src/main/index.ts` now performs one dynamic import of +`./startup/codex-launch-session-startup-capability` immediately after installing the +retained agent-hook capability. It installs the returned object in a typed, +fail-closed owner before Store construction or any live Codex consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,636,591 | 3,590,383 | -46,208 | 769,478 | 759,456 | -10,022 | + +The preload and renderer outputs were byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer audit + +Before this tranche, `index.ts` directly value-imported: + +- `markCodexProjectTrusted`; +- `normalizeCodexRuntimeSelection`; +- `codexHookService` and `setSystemCodexHomeHookSweepSuppressed`; +- `ensureRealHomeCodexHookState` and `isRealHomeCodexHookLaneUsable`; +- `setCodexTrustGrantTelemetry`; +- `startCodexSessionBackfillInBackground` and + `startCodexSessionIndexHealInBackground`; +- `createCodexSessionMigrationScheduler`; +- `prepareLegacySharedCodexSessionResume`; +- `resolveHostCodexSessionSourceHome`; +- `prepareCodexSessionResume`; and +- `getOrcaManagedCodexHomePath` and `getSystemCodexHomePath`. + +The production audit found these other importer groups: + +- retained runtime-service and core-IPC graphs consume agent-trust presets for local, + WSL, SSH, and remote worktree trust; +- the retained account-services graph consumes runtime selection, runtime-home + resolution, hook install material, session-source paths, and Codex home paths; +- the retained terminal-runtime graph consumes runtime selection, hook service, and + system-home resolution for PTY launch and WSL environment construction; +- the retained agent-hook graph consumes the same `codexHookService` singleton through + managed-hook controls, remote installers, WSL relay dependencies, and SSH relay + sessions; +- the retained Codex-usage Store graph and deferred core/native-chat paths consume + Codex home discovery without becoming new eager `index.ts` paths; and +- session backfill, index heal, legacy resume, and real-home hook modules form the same + internal Codex graph now rooted by the new aggregate. + +`index.ts` retains only the type-only `CodexAccountSelectionTarget` import from +runtime selection. Cross-domain leaves such as telemetry `track`, WSL distro +resolution, cross-platform path comparison, Store/runtime/account types, and agent-hook +enablement remain in their existing owners. + +Several target modules can already be evaluated by retained app-ready capabilities, +especially hook service and Codex home paths through the agent-hook aggregate. Node/Electron +module caching means the new aggregate returns those exact existing singleton and function +objects rather than creating alternate state. + +## Lifecycle and identity audit + +There is no supported pre-app-ready Codex launch or resume event. Before readiness, +`index.ts` only declares callbacks. Store hydration, account construction, runtime +construction, IPC registration, PTY registration, window creation, SSH relay creation, +and headless serve startup all happen inside or after `app.whenReady`. + +The retained order is: + +1. browser, main-window, terminal-runtime, updater-runtime, desktop-shell, and + agent-hook capabilities load and install; +2. the Codex launch/session aggregate loads once, returns the original exports, and is + installed in the typed owner; +3. certificate handling, CLI/WSL reconciliation, Store construction, hydration, and + telemetry initialization retain their existing positions; +4. trust-grant telemetry is injected after `initTelemetry` and before observability, + account, runtime, IPC, or renderer consumers; +5. account-service configuration installs the same real-home lane gate and system-home + sweep gate, then constructs the migration scheduler with the same eligibility, + quitting, home resolver, backfill, and index-heal identities; +6. runtime and window AI Vault callbacks keep the same legacy-resume function and live + settings/home resolution; +7. first-window and headless terminal registration keep the same synchronous launch + preparation and asynchronous session-resume callbacks; and +8. managed hook reconciliation, window opening, RPC readiness, serve promotion, and + teardown retain their original downstream order. + +The three functions declared before readiness—Codex runtime-home launch preparation, +session-resume preparation, and main-window creation—read the typed owner synchronously +and fail closed if called before installation. Their post-readiness callers receive the +original identities. No launch API became asynchronous. + +The capability factory only returns imported identities. It does not construct a new hook +service, create a scheduler early, mutate home selection, grant trust, install hooks, +start background work, or emit telemetry. + +## Preserved contracts + +- Real versus Orca-managed `CODEX_HOME` selection, account ranking, trusted-home + provenance, and legacy shared-home migration are unchanged. +- Synchronous project trust marking still occurs before recognized local Codex launch + and resume, with the same best-effort warning behavior. +- Real-home lane gating, hook sweep suppression, hook install/refresh policy, user-hook + preservation, trust grants, and telemetry field ordering are unchanged. +- Backfill and index healing remain scheduler-owned background work and retain quitting, + eligibility, initial-run, and account-selection triggers. +- Local, WSL, SSH, remote, and folder-workspace routing continues through the existing + providers and runtime services. No local-worktree assumption was added. +- Windows, macOS, and Linux paths continue through existing path utilities and runtime + gates. No Git command or provider behavior changed, so GitLab/other providers and the + Git 2.25 baseline are unaffected. + +## A/B artifacts and hashes + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- Fresh A: `/tmp/orca-codex-launch-session-a.3ehphi` +- Final B: `/tmp/orca-codex-launch-session-b.27Upad` +- A entry SHA-256: + `f215df1d66b8eea478f2910eb8ec0b2c697a218397c2384583c0f982569a5839` +- B entry SHA-256: + `f6242c0955c8a600a44912e21eb0bdd8296483b859c615b36e998896892cf527` + +Both sorted non-main manifests contain 786 rows / 89,303 bytes and have SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`. +Their direct diff is empty. + +The A main manifest contains 129 rows / 14,983 bytes and has SHA-256 +`315147af4d068bd8d59f1bfa666aaf546e7224c0b67606bcf521110e8be4fe0a`. +The B main manifest contains 131 rows / 15,238 bytes and has SHA-256 +`afbb9536871be0705da1b21523f5fbf30ecf990a00db13026f3e83c200a247f7`. + +## Emitted chunk and closure + +The retained build emits +`out/main/chunks/codex-launch-session-startup-capability-COnrYJZ9.js` at +47,423 raw bytes with SHA-256 +`b9f86937f39af7bea2ae4b108d5ca3e1aec31c210e9f2a7b01056f1f4ee87355`. +Its nine direct literal relative edges are: + +- `./chunk-BTjIgr6M.js` +- `./win32-utils-DtAFUr2N.js` +- `./fs-utils-D5115c5m.js` +- `./execution-host-DX_Sa8Eh.js` +- `./hook-service-BPcIgznJ.js` +- `./codex-app-server-client-C9VhVlb_.js` +- `./codex-home-paths-C8xszHr4.js` +- `../index.js` +- `./codex-session-file-listing-C1gM8eJG.js` + +The `../index.js` edge is the bundler's shared-entry cycle and preserves composition-root +state rather than duplicating it. + +An inclusive Acorn AST walk followed literal relative import, export, +dynamic-import, and `require` edges. The capability closure visited 121 JavaScript +files and validated 653 edges. A separate scan of all 131 emitted-main JavaScript +files validated 701 edges. Every resolved target exists and remains beneath +`out/main`; no edge escapes the emitted directory. + +## Budget + +The previous Electron-main raw budget was 3,684,827 bytes. Lowering only that value by +the exact 46,208-byte reduction produces 3,638,619 bytes: + +`3,638,619 - 3,590,383 = 48,236` + +Preload and renderer budgets are unchanged. + +## Validation + +- Fresh A and final B `pnpm run build:electron-vite`: passed; main transforms + changed from 1,991 to 1,993, preload remained 17, and renderer remained 9,181. +- Focused capability, owner, source-boundary, project trust, hook service, + real-home hook, trust grant/ledger/rollback/rebase, home path, runtime selection, + account service, launch, resume, legacy migration, backfill, index heal, + migration scheduler, PTY, runtime, account/agent-hook/terminal/runtime startup, + desktop/serve startup, shutdown checkpoint, dev-parent shutdown, and relaunch + coverage: 70 files / 2,190 passed and 6 skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions + and no new bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,590,383 actual versus + 3,638,619 budgeted Electron-main bytes. +- `git diff --check`: passed. + +The broad run initially exposed two stale source assertions in the previously retained, +untracked runtime-service boundary test: they still expected the pre-agent-hook +`agentHookServer` spelling. With coordinator authorization, only those two assertions +were updated to the already-retained `agentHooks.agentHookServer` identity. Production +code and ordering were not changed by that maintenance correction, and the affected +test passed in the final 70-file run. + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +## Remaining limitation + +The production builds and Acorn scans validate emitted relative dependency resolution +on this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, +Linux, and Windows; cross-platform packaged launch verification remains explicitly +unresolved. diff --git a/tools/benchmarks/results/phase1-codex-usage-store-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-codex-usage-store-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..562efa24867 --- /dev/null +++ b/tools/benchmarks/results/phase1-codex-usage-store-startup-boundary-2026-07-29.md @@ -0,0 +1,150 @@ +# Phase 1 CodexUsageStore startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `CodexUsageStore` construction out of the eager Electron main graph while + preserving synchronous Codex usage-path capture, store identity, scanning, snapshots, IPC, and + automation attribution. + +## Result + +`src/main/index.ts` now type-imports `CodexUsageStore`, eagerly imports only +`initCodexUsagePath` from the concrete `src/main/codex-usage/codex-usage-file-path.ts` module, +and awaits the dynamic `./startup/codex-usage-store-startup-capability` import at the original +construction site. The factory calls only `new CodexUsageStore(store)` with the original `Store` +object and returns that same live instance. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,852,413 | 7,794,590 | -57,823 | 1,641,212 | 1,630,202 | -11,010 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison also matched all 786 emitted files under `out/preload` and +`out/renderer`. + +## Importer and constructor evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of `src/main/codex-usage/store.ts`. Every other +production consumer was type-only: + +- `src/main/ipc/codex-usage.ts` +- `src/main/ipc/register-core-handlers.ts` +- `src/main/automations/service.ts` +- `src/main/automations/run-usage-collection.ts` + +After the edit, `src/main/startup/codex-usage-store-startup-capability.ts` is the sole production +value importer and direct constructor. `src/main/index.ts` and every IPC and automation consumer +remain type-only. `store.ts` retains a compatibility re-export of `initCodexUsagePath`. + +Only path state, synchronous initialization, and the cached fallback getter moved out of +`store.ts`. The class implementation changed only its two path getter call sites. Schema version +5 invalidation with enabled-state carry-forward, model pricing, reasoning-tier normalization, +atomic temporary-file persistence, scan gating, five-minute throttling and attribution window, +incremental scans and ownership deduplication, range/scope queries, snapshot construction, and +public APIs are unchanged. + +## Preserved path timing, ordering, and identity + +`initCodexUsagePath()` remains a direct synchronous call in the existing single-instance-lock +block: + +1. It retains its exact sequence after `initDataPath()`, session-parse-cache and profile-path + initialization, `initStatsPath()`, and `initClaudeUsagePath()`. +2. It remains before `initOpenCodeUsagePath()`, `app.whenReady()`, and + `app.setName(devInstanceIdentity.appName)`. +3. It still resolves exactly `/orca-codex-usage.json` with `path.join`. +4. The fallback getter still captures once on first access if startup initialization was skipped. + +The cross-platform path-state test changes the mocked late Electron user-data path after +initialization and proves reads stay on the early captured path. It also proves fallback capture +is cached. Both expectations use `path.join`, including on Windows. + +The awaited capability import and factory replace `new CodexUsageStore(store)` at its exact +former services point: after Claude usage, before OpenCode usage, before every consumer, and +before the `services-initialized` milestone. Construction therefore still finishes before +AutomationService creation and before any main-window IPC registration or readiness exposure. + +The same singleton remains protected by the existing `openMainWindow` initialization guard, +passed unchanged to `registerCoreHandlers`, and forwarded unchanged to +`registerCodexUsageHandlers`. The scan-state, enablement, refresh, snapshot, summary, daily, +breakdown, and recent-session IPC channels and arguments did not change. + +The same singleton is also injected into AutomationService immediately after `claudeUsage` in +the unchanged options object. Local completed Codex runs still call `getAutomationRunUsage` with +the run workspace, terminal session, start time, and collection time. The untouched run-usage +collector still returns `remote_usage_unavailable` for SSH automation before consulting local +usage logs, and it retains the existing unsupported-provider and unavailable-store mappings. + +The store still receives the original persistence `Store` object by identity and still uses +`loadKnownUsageWorktreesByRepo(this.store, repos)`. The usage metadata, Codex home discovery, and +scanner modules are untouched, preserving folder-workspace filesystem IDs, git-worktree +metadata, managed/system/per-account Codex homes, local/WSL/SSH/remote eligibility behavior, +canonical path ordering, alias handling, incremental file reuse, and fork-copy deduplication. +The cached dashboard snapshot remains synchronous and passed its existing 1,000 ms coarse +performance guard with 12,000 daily aggregates and 8,000 sessions. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/codex-usage-store-startup-capability-De9vPWQe.js` (56,947 raw / 11,456 gzip +bytes). `out/main/index.js` loads it through +`./chunks/codex-usage-store-startup-capability-De9vPWQe.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./worktree-id-B3lEXLSJ.js` +- `./codex-home-paths-_LPLI0JN.js` +- `./wsl-P2tsPUbL.js` +- `../index.js` +- `./usage-worktree-metadata-CK-sO7fO.js` + +These paths match packaged-relative module resolution. + +## Budget + +The prior `electron-main` raw budget was 7,900,649 bytes. Lowering it by the exact measured +57,823-byte improvement produces a new budget of 7,842,826 bytes and leaves 48,236 bytes +(0.619%) of headroom over the 7,794,590-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,966 main modules; the post-edit build transformed 1,968. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused Codex path, store, schema, pricing, reasoning normalization, automation attribution, + snapshot benchmark, scanner paths and aggregation, large-directory discovery, usage-worktree + metadata and canonicalization, Codex/core IPC identity, AutomationService and its startup + boundary, desktop/serve startup, and runtime startup-failure suite: passed, 18 files and 110 + tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,794,590 actual versus 7,842,826 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-core-ipc-registry-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-core-ipc-registry-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..720a330966e --- /dev/null +++ b/tools/benchmarks/results/phase1-core-ipc-registry-startup-boundary-2026-07-29.md @@ -0,0 +1,179 @@ +# Phase 1 core IPC registry startup boundary — 2026-07-29 + +**Scope:** Defer the aggregate desktop-only core IPC registry without making +`openMainWindow` asynchronous or changing handler registration, service identity, desktop +activation, headless serve, renderer readiness, runtime RPC, window recreation, or shutdown +contracts. + +## Result + +`src/main/index.ts` no longer value-imports `registerCoreHandlers`. It type-imports a +`CoreIpcRegistry` function signature, memoizes one dynamic import of +`./startup/core-ipc-registry-startup-capability`, and stores the returned exact register function +in a module-level slot. Normal desktop startup awaits that slot before the existing parallel +`openMainWindow()` and `runtimeRpc.start()` launch. Ordinary headless serve completes its RPC, +readiness, and return path without loading the registry. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,225,280 | 6,549,287 | -675,993 | 1,514,823 | 1,375,919 | -138,904 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison matched all 786 emitted files outside `out/main`, covering the +complete preload and renderer output. + +## Importer, caller, and reachable-graph audit + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole caller of `registerCoreHandlers`. The function has no constructor; its +module-level `registered` flag is the existing one-time registration authority. + +After the edit: + +- `src/main/startup/core-ipc-registry-startup-capability.ts` is the sole production value importer. +- `src/main/index.ts` has one type-only capability import, exactly one dynamic aggregate import, + one capability call, and no eager import of `ipc/register-core-handlers`. +- The capability returns the imported function itself without invoking, wrapping, or rebinding it. +- No direct handler import, handler call, schema, policy, service API, or IPC channel moved. + +A production-symbol comparison found 52 representative registration functions removed from the +static entry. They include app, CLI, filesystem, Claude/Codex/OpenCode usage, GitHub/GitLab/hosted +review, Linear/Jira, feedback, crash reporting, export, stats, memory, rate limits, runtime +environment, ephemeral VM, AI Vault, native chat, notebook, onboarding, dashboard popout, +terminal preview, permissions, sessions, settings, diagnostics, skills, workspace spaces/ports, +automation, keybindings, telemetry, shell, pets, emulator streams, profile/account, agent-hook, +trust, and MiniMax/Grok account handlers. + +Six representative handler symbols remain eager because production paths outside the desktop +core registry still use their modules: + +- `registerPreflightHandlers` through runtime/RPC/Linear/Jira paths; +- `registerFilesystemWatcherHandlers` through runtime and index cleanup; +- `registerNotificationHandlers` through startup notification and RPC paths; +- `registerBrowserHandlers` through runtime browser paths; +- `registerPluginHandlers` through runtime plugin RPC; +- `registerUIHandlers` through window/dashboard/GitHub/UI trust paths. + +This is the coherent boundary available without redesigning the already-shared runtime graphs. + +## Interface, loading, and activation + +The startup capability exports only: + +```ts +export type CoreIpcRegistry = typeof registerCoreHandlers +export function getCoreIpcRegistryStartupCapability(): CoreIpcRegistry +``` + +Index owns both the resolved function and the memoized import promise. Concurrent activation, +startup, or reopen requests therefore share one module load and one function identity. + +The existing activation sources remain intact: + +- the second-instance lock and macOS activation still call `requestDesktopActivation`; +- normal desktop activation is immediately eligible, as before; +- serve activation remains queued while the serve gate is `initializing`, then either runs after + `markReady()` or reports the existing persistent-PTY-provider block; +- tray reopen still restores an existing window synchronously and uses the registry guard only + when it must recreate one; +- an early Settings menu request waits for the same registry promise, then recreates the window + synchronously and sends the same `ui:openSettings` message plus one-shot renderer intent; +- update quit still fences every activation before loading or opening a window. + +The narrow `runDesktopActionWhenCoreIpcReady` guard does not create a second readiness state. +It checks the exact registry slot, uses the memoized loader, preserves the updater fence, and +then calls the existing synchronous focus, reopen, or settings action. Load failures remain +best-effort and are reported through the existing main-process warning surface. + +## Desktop, headless, and registration order + +Normal desktop startup preserves this order: + +1. construct and wire every Store, account, usage, automation, plugin, runtime, lifecycle, and + window dependency; +2. record `services-initialized`, initialize main i18n/menu state, install activation listeners, + and start terminal runtime startup services; +3. await the single core-registry loader; +4. begin the unchanged parallel `openMainWindow()` and `runtimeRpc.start()` calls; +5. inside synchronous `openMainWindow`, capture and guard the loaded register function before + constructing `BrowserWindow`; +6. create the window and install the existing `did-finish-load` listener; +7. call `registerCoreHandlers` at its original point; +8. attach automation web contents/start, attached window services, rate limits, diagnostics, + renderer loading, and renderer readiness in their original order. + +The handler call receives the same live objects by identity: Store, runtime, stats, +Claude/Codex/OpenCode usage stores, Codex/Claude accounts, rate limits, renderer webContents ID, +automation service, launch preparation callbacks, agent-awake and crash-report services, +keybindings, AI Vault/relaunch/profile-auth lifecycle callbacks, plugin service, marketplace, and +installer. + +`openMainWindow` remains non-async. Later window recreation reuses the stored function, while the +existing `registered` flag prevents duplicate channel installation. BrowserWindow creation, +renderer load/readiness, PTY/automation attachment, updater/relaunch behavior, and quit callbacks +are otherwise unchanged. + +The ordinary headless serve branch never calls the loader or registry. It still registers +headless PTY runtime support, conditionally attaches the offscreen browser backend, syncs the +empty window graph, awaits `runtimeRpc.start()`, settles desktop activation, installs signals and +CLI support, starts automations, publishes readiness, and returns. A later supported +serve-to-desktop activation loads the same registry only when the activation gate actually asks +for a desktop window. + +## Generated chunks and packaged-relative resolution + +The emitted capability is +`out/main/chunks/core-ipc-registry-startup-capability-CxBsaHMF.js` +(680,906 raw / 139,680 gzip bytes). `out/main/index.js` loads it through +`./chunks/core-ipc-registry-startup-capability-CxBsaHMF.js`. + +The entry specifier is relative, contains no parent traversal, and resolves beneath `out/main`. +An emitted-tree scan checked 78 JavaScript files and 295 static relative `require`/`import` +references. Every resolved dependency exists and remains under `out/main`; this includes all +relative dependencies reachable from the capability chunk. + +## Budget + +The prior `electron-main` raw budget was 7,273,516 bytes. Lowering it by the exact measured +675,993-byte reduction produces a new budget of 6,597,523 bytes and leaves exactly 48,236 bytes +(0.736%) of headroom over the 6,549,287-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,972 main modules and the retained build transformed 1,973; both transformed 17 preload + modules and 9,181 renderer modules. +- Focused capability and source-boundary tests: passed, 2 files with 7 tests. +- Representative core handler, account/profile, plugin/marketplace/ephemeral-VM, AI Vault, + browser, rate-limit, automation, runtime RPC, window creation/focus/recreation, macOS + activation, tray, desktop/serve activation and readiness, runtime-startup failure, renderer + shutdown, auth preservation, relaunch, and updater suite: passed, 31 files with 404 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 6,549,287 actual versus 6,597,523 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The production build, emitted-tree dependency scan, and relative-path checks prove the emitted +packaged-relative layout on this macOS worktree. This tranche did not run a packaged ASAR launch +smoke on macOS, Linux, or Windows. Cross-platform packaged launch verification remains the +residual limitation. diff --git a/tools/benchmarks/results/phase1-crash-hang-runtime-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-crash-hang-runtime-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..c4ce25c071b --- /dev/null +++ b/tools/benchmarks/results/phase1-crash-hang-runtime-startup-boundary-2026-07-29.md @@ -0,0 +1,184 @@ +# Phase 1 crash/hang runtime startup boundary — 2026-07-29 + +**Scope:** Move the app-ready crash report, hang watchdog, recovery-classification, +and process-gone composition values behind one exact-identity startup capability while +retaining the two proven pre-ready leaves and all GPU fallback policy. + +## Result + +`src/main/index.ts` now dynamically imports +`./startup/crash-hang-runtime-startup-capability` immediately after installing the +telemetry/observability capability. The returned object is installed in a typed, +fail-closed owner before constructing the crash report store, installing the watchdog, +consuming the previous-run marker, registering the certificate handler, changing the +app name, or starting any window/runtime consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,573,732 | 3,557,573 | -16,159 | 755,124 | 750,742 | -4,382 | + +The preload and renderer outputs remained byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer audit + +Before this tranche, `index.ts` eagerly value-imported: + +- `recordCrashBreadcrumb` and `recordCoalescedCrashBreadcrumb`; +- `recordDurableCrashBreadcrumb`; +- `installMainThreadHangWatchdog`; +- `consumeHangDetectionMarker` and `hangDetectionMarkerPath`; +- `getMainProcessLifecycleIdentity`; +- `CrashReportStore`; +- `shouldRecoverRendererAfterProcessGone`; and +- `recordProcessGoneCrash`. + +The complete production importer audit found: + +- `crash-breadcrumb-store` is also used by durable/process-gone recording, system + resume, PTY/filesystem/crash IPC, and GitHub PR refresh coalescers. +- `durable-crash-breadcrumb` is also used by app relaunch, updater lifecycle + diagnostics, the main-process error guards, and process-gone recording. +- `hang-detection-marker` is shared with the watchdog and its child entry; + `main-thread-hang-watchdog` otherwise had only the direct `index.ts` importer. +- `main-process-lifecycle-identity` is shared by durable and process-gone recording. +- Other production imports of `CrashReportStore` are type-only in core and + crash-reporting IPC registration. +- `process-gone-classification` is also used by the recorder and as a type by its + diagnostics module; `process-gone-recorder` otherwise had only the direct + `index.ts` importer. + +ES module caching keeps one identity for every value regardless of whether another +retained graph reaches the module first. The capability returns those exact imports; +it does not wrap, recreate, or copy their state. + +## Lifecycle audit + +Two calls genuinely precede `app.whenReady()` and therefore remain narrow eager +imports: + +- `recordCrashBreadcrumb('app_started', ...)`, after canonical data-path capture and + only when the single-instance lock is owned; and +- `getMainProcessLifecycleIdentity()` for that breadcrumb. + +`maybeApplyGpuFallbackForThisLaunch()` also runs before readiness because Electron's +hardware-acceleration switch must be applied before the app is ready. Its marker, +tracker, thresholds, candidate decision, restart prompt, and direct +`gpu_fallback_applied` breadcrumb remain eager and unchanged. + +The prior `CrashReportStore.fromUserData()` call only captured a path. There was no +pre-ready process-gone listener, renderer, window, crash-report IPC handler, or report +read/write. It now runs at the first safe app-ready point, still before `app.setName`, +so its default `app.getPath('userData')` input and canonical-path timing are unchanged. +The store class, write chain, Windows ACL recovery, sanitization, dedupe, and report +ownership are untouched. + +The watchdog installation and previous marker consumption remain adjacent and keep +the canonical user-data input. Marker-to-durable-breadcrumb ordering is unchanged; +the later telemetry event still occurs only after telemetry initialization. + +Predeclared live functions use the required owner for agent-state coalescing, settings +breadcrumbs, renderer recovery/classification, manual reloads, GPU child-crash +handling, and process-gone persistence. Every such callback is installed or reachable +only after capability installation. `will-quit` has no crash/hang-capability consumer, +so no optional early-quit owner path was necessary. + +No renderer/child recovery policy, metadata, `CrashReportStore` ownership, main-window +recreation, headless serve behavior, or shutdown ordering moved into the capability. +SSH/remote, WSL, and folder-workspace paths continue through their existing runtime +and PTY boundaries. No Git command or provider behavior changed. + +## A/B evidence and hashes + +Fresh production evidence: + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A: `/tmp/orca-crash-hang-runtime-a.2SGiME` +- B: `/tmp/orca-crash-hang-runtime-final-b.ys96bL` +- A transformed 1,995 main, 17 preload, and 9,181 renderer modules. +- B transformed 1,997 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry hashes: + +- A `out/main/index.js` SHA-256: + `196f22d886cf0e6d534b1ea6ed73ece9a3bf561fb55a252a47109f5b0313bbfc` +- B `out/main/index.js` SHA-256: + `ee3964bc76235c05c55ea67e53408dddabfa88ffec6e958de33323420c9b2fe6` + +The sorted A and B non-main manifests each contain exactly 786 rows and are +byte-identical. Their SHA-256 is +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`. +An explicit `diff -u` produced no output, and all 786 B files passed verification +against the A manifest. + +The A main manifest contains 134 rows with SHA-256 +`2ef872fbaf337b2068950ff1a4593017fc64e29f9dc54fd7954b6cecf862fcb9`. +The B main manifest contains 135 rows with SHA-256 +`4bc1da573d6dd46d3167ce729de32f55d91f9339ce2fb974776992255d1daf13`. + +## Emitted chunk and closure + +The retained build emits +`out/main/chunks/crash-hang-runtime-startup-capability-D0EviH4j.js` at 18,987 raw +bytes with SHA-256 +`4a2993ecba5a67781daab5215ad0c25a64e50f59d80e2205cc8383ba3832d2ea`. +Its four direct literal relative edges are: + +- `./chunk-BTjIgr6M.js` +- `./win32-utils-DtAFUr2N.js` +- `../index.js` +- `./hang-detection-marker-CisIg30K.js` + +The `../index.js` edge is the bundler's shared-entry cycle and preserves existing +singleton state rather than duplicating it. + +An inclusive Acorn AST walk followed literal relative import, export, +dynamic-import, and `require` edges. The capability closure visited 125 JavaScript +files and validated 673 edges. A separate scan of all 135 emitted-main JavaScript +files validated 721 edges. Every resolved target exists and remains beneath +`out/main`; no edge escapes the emitted directory. + +## Budget arithmetic + +The previous Electron-main raw budget was 3,621,968 bytes. Lowering only that value by +the exact 16,159-byte reduction produces 3,605,809 bytes: + +`3,605,809 - 3,557,573 = 48,236` + +Preload and renderer budgets are unchanged. + +## Validation + +- Fresh A and B production Electron/Vite builds: passed. +- Focused capability, owner, source-boundary, crash breadcrumb/report, + durable-breadcrumb, lifecycle identity, process-gone classification/dedupe/ + diagnostics/recording, renderer recovery, GPU fallback, hang marker/watchdog/ + telemetry/child-loop, crash IPC, relaunch, main-process error guard, main-window + create/attach, startup ordering, serve activation, runtime capability, renderer + shutdown checkpoint, and quit-policy suite: 36 files / 319 tests passed. +- SSH-rearm, remote runtime request, WSL terminal host, folder-workspace path, + headless-display, and PTY startup-order representatives: 6 files / 30 tests passed. +- Aggregate test result: 42 files / 349 tests passed. +- `pnpm run typecheck:node`: passed. +- Targeted `oxlint --deny-warnings`: passed. +- Targeted `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with no new bypasses. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +## Platform limitation + +The source, unit/integration tests, and generated-closure validation cover the +cross-platform gates and retain the existing Windows path/ACL, macOS lifecycle, Linux +headless, WSL, SSH/remote, and folder-workspace code. Packaged-ASAR launch verification +was not run on macOS, Linux, and Windows and remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-dashboard-popout-root-store-boundary-audit-2026-07-30.md b/tools/benchmarks/results/phase1-dashboard-popout-root-store-boundary-audit-2026-07-30.md new file mode 100644 index 00000000000..ad86b21a2d4 --- /dev/null +++ b/tools/benchmarks/results/phase1-dashboard-popout-root-store-boundary-audit-2026-07-30.md @@ -0,0 +1,180 @@ +# Phase 1 dashboard-popout root-store boundary audit — 2026-07-30 + +**Result:** rejected. The smallest measured popout-only candidate did not remove the root +renderer store from the popout static closure and increased both other renderer entries. +Only this report is retained; all candidate source edits were removed and no budget changed. + +## Production import and behavior audit + +The accepted source graph has two eager root-store consumers in the popout bootstrap: + +1. `src/renderer/popout.html` loads `src/renderer/src/popout.tsx`. +2. `popout.tsx` directly imports `useAppStore` from `./store`. +3. The module-level synchronous settings read seeds `useAppStore` before React mounts, and + `PopoutSettingsSync` reads settings, fetches keybindings, merges settings change events, + hydrates the async settings result, and reapplies theme/font changes. +4. `popout.tsx` also imports `I18nProvider`; that provider independently imports + `useAppStore` to select `settings.uiLanguage` and combines it with the standalone plugin + language-pack store. + +The dashboard branch is: + +`popout.tsx` → `DashboardPopoutRoot.tsx` → `AgentKanbanBoard.tsx` → +`AgentKanbanCard.tsx` / `AgentDashboardToolbar.tsx` / `AgentTerminalDialog.tsx`. + +None of those dashboard source modules imports the root store. In A, however, Rolldown emits +the board as `assets/AgentKanbanBoard-Bsagvlaj.js`, and its static import table includes +`assets/store-C4ip2Tem.js`. The emitted board import obtains +`agentTypeToIconAgent`, `formatAgentTypeLabel`, and `DEFAULT_APP_FONT_FAMILY` from that chunk: +the first two originate in the board's `@/lib/agent-status` dependencies, while the font +constant reaches the shared chunk through `popout.tsx` → `lib/app-font-family.ts` → +`shared/constants.ts`. Thus the manifest has both exact static paths: + +- `popout.html` → `_store-C4ip2Tem.js` +- `_AgentKanbanBoard-Bsagvlaj.js` → `_store-C4ip2Tem.js` + +The retained AgentTerminalPreview boundary is not an eager source edge. +`AgentTerminalDialog.tsx` dynamically imports `AgentTerminalPreview.tsx` only for a live +`ptyId`. Inside that lazy graph, `AgentTerminalPreview.tsx` reads live settings/keybindings +from `useAppStore`, and `preview-terminal-key-handler.ts` reads keybinding overrides for +copy/paste policy. Those reads preserve terminal theme/font/ligatures, macOS Option handling, +macOS `Meta` versus Linux/Windows `Ctrl` clipboard chords, Kitty input policy, and user +overrides. Moving popout settings to local React state alone would stop seeding those lazy +consumers and is not behaviorally equivalent without a new typed preference boundary. + +AgentTerminalPreview continues to receive the relayed `terminalInput` profile and uses the +existing terminal-preview preload APIs; this preserves local, WSL, SSH, and remote-runtime +byte routing. Dashboard cards and reveal payloads retain repo/worktree IDs without assuming +a git worktree, so folder-workspace behavior is unchanged. No provider, plugin-language-pack, +keybinding, dialog, drawer, focus, Escape, click-outside, or routing owner was changed. + +## Candidate audit + +Two reversible measurements were made after A: + +1. Replacing only the direct `popout.tsx` settings store usage with local React state reduced + popout static raw by 166 bytes, from 4,507,253 to 4,507,087, but + `_AgentKanbanBoard-Bsagvlaj.js` still statically imported `_store-C4ip2Tem.js`. This also + omitted the existing keybinding hydration and was therefore not a behaviorally complete + candidate. +2. The smallest popout-only structural boundary then loaded `AgentKanbanBoard` through + `lazyWithRetry`/`Suspense` in `DashboardPopoutRoot`. This removed the board-to-store static + edge, but the popout still statically reached `assets/store-BsaMDYw-.js` through + `I18nProvider`. Automatic chunk repartitioning also increased the main and web renderer + static closures. + +The second measurement is the rejected B below. It fails three retention requirements: + +- root store remains statically reachable from `popout.html`; +- main renderer raw/count increased; and +- web renderer raw/count increased. + +A behavior-complete removal would therefore require at least a store-free popout i18n owner, +a typed settings/keybinding preference owner for the lazy preview, and isolation of the board +chunk's shared allocations. That is a multi-owner store partition rather than the requested +small boundary, so it was not attempted. + +## Fresh A/B production evidence + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-popout-root-store-a.5VHJvi` +- Rejected B artifact: `/tmp/orca-popout-root-store-b-rejected.GeNJ8h` +- Restored output was rebuilt after removing the candidate and is file-for-file identical to + A across all 972 output files. Its sorted output manifest SHA-256 is + `f93152dd5325963b0482fdca8310c0652837c848d6d3c3a4fce5c530aea4e581`. +- A, B, and the restored build each transformed 2,003 main, 17 preload, and 9,181 renderer + modules. Each emitted only the two existing CSS `::highlight(...)` parser warnings. + +| Static closure | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | A JS | B JS | A CSS | B CSS | +| ---------------- | --------: | --------: | ---------: | --------: | --------: | ----------: | ---: | ---: | ----: | ----: | +| Main window | 8,798,746 | 8,803,798 | +5,052 | 1,969,591 | 1,972,921 | +3,330 | 292 | 297 | 2 | 2 | +| Dashboard popout | 4,507,253 | 4,130,954 | -376,299 | 984,615 | 875,364 | -109,251 | 77 | 28 | 2 | 1 | +| Web renderer | 4,360,652 | 4,363,659 | +3,007 | 928,352 | 930,785 | +2,433 | 33 | 36 | 1 | 1 | + +Electron entries were file-for-file identical: + +| Entry | A/B raw | A/B gzip | A/B SHA-256 | +| ------- | ------: | -------: | ------------------------------------------------------------------ | +| Main | 776,873 | 174,092 | `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd` | +| Preload | 130,798 | 20,642 | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` | + +The complete main trees each contain 184 files and have sorted manifest SHA-256 +`30ad89a0187516526b13c69c58fd76ba3a853ad3c2707080cd17206b910ee38f`. +The preload trees each contain one file and have sorted manifest SHA-256 +`258a659699aac742ff29ce6bb44d9b9a43dca8a7b449c203821f809df64d6ce0`. +The renderer manifests contain 778 A entries and 784 B entries with SHA-256 +`471f551a8431804bccd958cdf7ba831451e7560ae1422e5ade93030e0fcad90c` and +`51ee225c6521512a6d7b8b3bbb31bb11fa333079a5338525b33f1432942e5238`, +respectively. + +Sorted static-closure manifests include path, raw, gzip, and file SHA-256: + +| Entry | A rows / manifest SHA-256 | B rows / manifest SHA-256 | +| ------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Main | 294 / `02ec537878e1782f937ecc28eaad58fa8d0b0ab88bffbf09365755a1256c5df5` | 299 / `0558b1b56828e1aa029f9dfe862dc3d794029a6afca4efd41c57f88b2a2c52c9` | +| Popout | 79 / `a445cebbc0de337cd09fc1278e1a27bf28271b21bbef8b42b3d614df15aa8f5d` | 29 / `b26a5663b0cb878e83bf1e70f29deb5dc58b332f619817af4e8d7e3667fcc044` | +| Web | 34 / `8c462ab4027db1482fc4b38975bd46adb1aea66f366e3e68261c80aaff8e71ac` | 37 / `8049431c6405efe223bf05b40de3ef28f13f3429244f8b495deff1fd0717ca6e` | + +The A root-store artifact is `assets/store-C4ip2Tem.js`: 1,950,409 raw / 390,353 +gzip, SHA-256 +`cb139656cd20b778d71dd636603b84649a1767c2f8129d5a99dc775e17dece0b`. +The B artifact is `assets/store-BsaMDYw-.js`: 1,884,306 raw / 375,376 gzip, +SHA-256 +`99baff55d754e5e96607688036e09e426373d1994ca46ad55e948bf07bca08e6`. +B moves the board to a dynamic edge but still has the exact static path +`popout.html` → `_store-BsaMDYw-.js`. + +Electron main remains within its 825,109 raw budget with exactly 48,236 bytes of headroom: + +`825,109 - 776,873 = 48,236` + +No renderer or Electron budget was edited. + +## Manifest and emitted-closure validation + +Every manifest import key, dynamic-import key, JavaScript/CSS file, and declared asset exists +beneath its renderer artifact root: + +| Arm | Manifest entries | Static edges | Dynamic edges | Emitted references | Failures | +| --- | ---------------: | -----------: | ------------: | -----------------: | -------: | +| A | 778 | 6,248 | 213 | 860 | 0 | +| B | 784 | 6,532 | 214 | 866 | 0 | + +An inclusive Acorn walk followed every literal relative static import, export, and dynamic +import from each emitted entry: + +| Arm / entry | JS files | Relative edges | Missing, escaping, or parse failures | +| ----------- | -------: | -------------: | -----------------------------------: | +| A main | 694 | 6,291 | 0 | +| A popout | 119 | 483 | 0 | +| A web | 695 | 6,317 | 0 | +| B main | 699 | 6,554 | 0 | +| B popout | 125 | 536 | 0 | +| B web | 700 | 6,583 | 0 | + +## Validation + +- Fresh A, rejected B, and restored `pnpm run build:electron-vite`: passed. +- Restored output versus A: 972/972 files identical. +- Focused dashboard popout, dialog, board, preview, keybinding, routing, and i18n suite: + 15 files and 125 tests passed. +- `pnpm run typecheck:web`: passed. +- Targeted `oxlint --deny-warnings`: passed. +- Targeted `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +## Residual risk + +The root store remains intentionally eager in the accepted popout build. A later tranche must +design the i18n and terminal preference owners together and remeasure Rolldown's cross-entry +chunk allocation; changing only the obvious direct import is misleading. + +No packaged launch smoke was run on macOS, Linux, or Windows. Production builds, manifest +validation, and emitted AST closure validation were run on this macOS worktree, so packaged +cross-platform launch behavior remains unresolved. diff --git a/tools/benchmarks/results/phase1-dashboard-terminal-preview-lazy-boundary-2026-07-30.md b/tools/benchmarks/results/phase1-dashboard-terminal-preview-lazy-boundary-2026-07-30.md new file mode 100644 index 00000000000..b199de33ce1 --- /dev/null +++ b/tools/benchmarks/results/phase1-dashboard-terminal-preview-lazy-boundary-2026-07-30.md @@ -0,0 +1,151 @@ +# Phase 1 dashboard terminal-preview lazy boundary — 2026-07-30 + +**Scope:** Defer `AgentTerminalPreview.tsx` and its terminal-only renderer graph until +`AgentTerminalDialog` renders a card with a live `ptyId`, without beginning the root-store +partition. + +## Result + +`AgentTerminalDialog.tsx` now loads `AgentTerminalPreview` through `lazyWithRetry` only inside +the existing live-PTY branch. The Suspense fallback preserves the preview's existing viewport +height, width, padding, and background while the chunk loads. + +| Renderer static closure | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | A JS | B JS | A CSS | B CSS | +| ----------------------- | --------: | --------: | ---------: | --------: | --------: | ----------: | ---: | ---: | ----: | ----: | +| Main window | 9,815,193 | 8,798,746 | -1,016,447 | 2,190,210 | 1,969,591 | -220,619 | 294 | 292 | 3 | 2 | +| Dashboard popout | 5,894,305 | 4,507,253 | -1,387,052 | 1,297,290 | 984,615 | -312,675 | 83 | 77 | 3 | 2 | +| Web renderer | 4,360,776 | 4,360,652 | -124 | 928,388 | 928,352 | -36 | 33 | 33 | 1 | 1 | + +No renderer entry increased. Electron main remained 776,873 raw / 174,092 gzip, and preload +remained 130,798 raw / 20,642 gzip. + +## Production importer and behavior audit + +Before editing, `AgentTerminalDialog.tsx` was the only production importer of +`AgentTerminalPreview`. `AgentKanbanBoard.tsx` owns the dialog and is shared by: + +- the separate dashboard popout through `DashboardPopoutRoot.tsx`; and +- the in-window dashboard drawer through `AgentDashboardDrawer.tsx`. + +The preview's static graph included xterm and its CSS, terminal appearance/theme composition, +the app store, input signal tracking, Kitty keyboard state, IME compatibility, paste/copy +routing, ligatures, grid claiming, shortcut-platform policy, and the terminal preview preload +API. None of those implementations moved or changed. + +The dialog shell remains eager. Its card header, close button, no-live-terminal message, Open +worktree action, `Dialog` open state, `onOpenChange`, reveal payload, click-outside behavior, +and focus/Escape handlers are unchanged. A card without `ptyId` still renders the closed-pane +message immediately and does not request the lazy module. A live `ptyId` renders the +dimension-preserving fallback, then passes the same `ptyId` and `terminalInput` identity into +the preview. + +The preview still focuses xterm after the live snapshot paints. Escape remains intercepted +only when its target is inside `.xterm`; Radix click-outside behavior remains owned by the +dialog. Terminal input, app-menu paste, clipboard paste, keybindings, Kitty mode, host input +profile, theme/font/ligature updates, SSH/runtime PTY routing, and unsubscribe/dispose behavior +remain in `AgentTerminalPreview` and its existing modules. No provider, workspace, path, +platform, or store policy changed. + +The lazy boundary uses reload key `dashboard-agent-terminal-preview`, following the existing +retry/reload conventions. It does not add a new store boundary or modify either dashboard +root. + +## Fresh A/B evidence + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-dashboard-preview-a.bye4Ur` +- B artifact: `/tmp/orca-dashboard-preview-b.hcsBwl` +- Both builds transformed 2,003 main, 17 preload, and 9,181 renderer modules. +- Both emitted only the two existing CSS `::highlight(...)` parser warnings. + +Electron entry hashes were unchanged: + +- main SHA-256: + `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd` +- preload SHA-256: + `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` + +The complete A and B main manifests each contain 184 rows, are byte-identical, and have +SHA-256 `3576805e0f10c1c6c3ca473257901f824326f4e2b1a0a224bbb50e72eb28a5f2`. +The preload manifests each contain one row, are byte-identical, and have SHA-256 +`3bb30bdb361c7c99cc423e4a4939399f8cb29042d653bdbfe5ef582034d9ed00`. + +Renderer manifest SHA-256: + +- A: + `7f7495f0cea8ef15df213841a3e2ede31f0867bd172b2f050d69afa02998095b` +- B: + `471f551a8431804bccd958cdf7ba831451e7560ae1422e5ade93030e0fcad90c` + +Sorted static-closure manifests: + +| Entry | A rows | A manifest SHA-256 | B rows | B manifest SHA-256 | +| ------ | -----: | ------------------------------------------------------------------ | -----: | ------------------------------------------------------------------ | +| Main | 297 | `bd65e941cd75035cd7b5d6f1e027a090ba67ed0d0a397a58bd7d8468864b77af` | 294 | `dd6c97cc5a75469e0c34cf13ba151d20193bf1491916962f956fb587fd8401a5` | +| Popout | 86 | `6a9828657de40275fb00a6c3caf2ff23add7dd1ed8208c8d420a2b2857b9f1bf` | 79 | `81498ed245318f0b027106a450854db8243163b95006f23b10e7fb1c38e15824` | +| Web | 34 | `faac8fc0fd25c148ddc7ba44e686a683acca9fda7bf5b8d988a3aefda776c699` | 34 | `8c5c1a2232fd581fe45af47a58db0bdded2ff7ec8e97696debbcc2eef55417d1` | + +Rows include JavaScript and CSS files and contain each emitted file's SHA-256 plus its +renderer-relative path. + +## Lazy entry and inclusive validation + +The retained build emits +`out/renderer/assets/AgentTerminalPreview-lt0BHchT.js` at 24,766 raw / 6,688 gzip bytes with +SHA-256 `38a9279d8ac0fe51835c3a0c392dbf7711f3c2b60fd514557fefd779969f9018`. + +Its manifest-static closure contains 21 JavaScript files and one CSS file: 4,354,274 raw / +1,001,727 gzip bytes. The preview entry is not in the main-window, popout, or web static +closure. `AgentKanbanBoard-Bsagvlaj.js` retains the dynamic edge and Vite preload map, including +the xterm stylesheet. + +Validation covered the complete B Vite manifest: + +- 778 manifest entries; +- 6,248 static import-key edges; +- 213 dynamic import-key edges; +- 860 emitted JavaScript/CSS/asset references. + +Every manifest key and emitted target exists beneath `out/renderer`. An inclusive Acorn AST +walk from the lazy entry followed literal relative import, export, and dynamic-import edges +through 59 JavaScript files and validated 226 edges. No target was missing and no path escaped +the renderer output. + +## Budget ratchets + +Only reduced renderer budgets changed, by each exact retained improvement: + +| Entry | Raw budget | Raw headroom | JS budget | JS headroom | CSS budget | CSS headroom | +| ------ | ---------: | -----------: | --------: | ----------: | ---------: | -----------: | +| Main | 8,848,553 | 49,807 | 293 | 1 | 2 | 0 | +| Popout | 4,537,948 | 30,695 | 78 | 1 | 2 | 0 | +| Web | 4,384,876 | 24,224 | 34 | 1 | 1 | 0 | + +Electron main remains budgeted at 825,109: + +`825,109 - 776,873 = 48,236` + +No budget increased. + +## Tests and validation + +- Fresh A and B `pnpm run build:electron-vite`: passed. +- Focused lazy-boundary, live-PTY load, dialog prop routing, preview input/copy/paste/focus, + board/card lifecycle, popout snapshot/relay, settings menu, and app startup-routing suite: + 9 files and 97 tests passed. +- `pnpm run typecheck:web`: passed. +- Targeted `pnpm exec oxlint --deny-warnings`: passed. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +## Remaining limitation + +The production builds, manifest validation, and emitted AST closure were run on this macOS +worktree. No packaged launch smoke was run on macOS, Linux, or Windows, so packaged +cross-platform launch coverage remains unresolved. diff --git a/tools/benchmarks/results/phase1-desktop-relay-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-desktop-relay-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..e78e3159d6d --- /dev/null +++ b/tools/benchmarks/results/phase1-desktop-relay-startup-boundary-2026-07-29.md @@ -0,0 +1,131 @@ +# Phase 1 DesktopRelayService startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `DesktopRelayService` construction out of the eager Electron main graph + while preserving cloud-auth gating, runtime and window ordering, live-service identity, pairing + provider wiring, relay status publication, and lifecycle fencing. + +## Result + +`src/main/index.ts` now type-imports `DesktopRelayService` and awaits the dynamic +`./startup/desktop-relay-service-startup-capability` import at the original construction site. +The capability calls only `new DesktopRelayService(options)` with the original options object and +returns that same live instance. `RelayBrokerStatus` remains an eager type-only import from +`relay-session-broker`. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,794,590 | 7,731,484 | -63,106 | 1,630,202 | 1,616,703 | -13,499 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison also matched all 786 emitted files under `out/preload` and +`out/renderer`. + +## Importer and constructor evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of +`src/main/runtime/relay/desktop-relay-service.ts`. There were no other production +`DesktopRelayService` consumers. `src/main/index.ts` and `src/main/ipc/mobile.ts` imported +`RelayBrokerStatus` type-only; relay-internal status consumers were also type-only except for the +existing `RelaySessionBroker` value import in the service implementation. + +After the edit, +`src/main/startup/desktop-relay-service-startup-capability.ts` is the sole production value +importer and direct constructor. `src/main/index.ts` remains the only production owner of the +live service reference and now imports its class type-only. The service and every relay +implementation module are otherwise untouched, preserving connection, authentication, +demand-ledger, revoke-outbox, HTTP/control, device authorization, and E2EE behavior. + +## Preserved gate, ordering, identity, and lifecycle + +The awaited capability import and construction remain inside the existing +`if (cloudAuth.configured) { try { ... } }` block. Unconfigured cloud auth still performs no +relay import or construction. Import or construction failures still reach the unchanged warning +fallback, `[relay] Desktop relay startup unavailable:`, without changing desktop readiness. + +Desktop startup order remains: + +1. `openMainWindow()` and `runtimeRpc.start()` run in the existing `Promise.all`. +2. Runtime RPC failure handling remains before cloud-auth evaluation. +3. The configured-cloud-auth `try` dynamically loads the capability and constructs with the + original `authConfig`, profile user-data path, app version, `runtimeRpc`, and status callback. +4. The returned live instance is assigned to `desktopRelayService`. +5. The mobile relay pairing provider is installed with closures over that same local instance. +6. `relayService.start()` runs only after assignment and provider wiring. + +The status starts at the unchanged `'offline'` default. The original callback still assigns +`desktopRelayStatus` before sending `mobile:relayStatusChanged` through the current main window. +The mobile IPC status getter continues to read the same global value. + +Pairing creation, queued revoke handling, demand changes, endpoint discovery, and provisioning +continue to call the same live service through the unchanged pairing-provider closures. The +original `runtimeRpc` object is passed into construction and used for the provider, so runtime +RPC identity and main-window ordering are unchanged. + +The same global instance remains referenced by the relaunch callback, Orca profile auth-mutation +callback, profile sign-out callback, and `before-quit` handler. Relaunch, sign-out, and quit still +call `fenceAndCloseNow()`; auth mutation still calls `authMutated()`. Quit still fences the relay +before clearing the runtime pairing provider. No relay API, cloud configuration rule, status +contract, account/rate-limit/plugin behavior, or SSH/folder-workspace path changed. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/desktop-relay-service-startup-capability-BcByl8gs.js` (64,047 raw / 14,073 gzip +bytes). `out/main/index.js` loads it through +`./chunks/desktop-relay-service-startup-capability-BcByl8gs.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./tui-agent-config-CgQCoDXB.js` +- `../index.js` + +These paths match packaged-relative CommonJS resolution. + +## Budget + +The prior `electron-main` raw budget was 7,842,826 bytes. Lowering it by the exact measured +63,106-byte improvement produces a new budget of 7,779,720 bytes and leaves exactly 48,236 bytes +(0.624%) of headroom over the 7,731,484-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,968 main modules; the post-edit build transformed 1,969. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused DesktopRelayService, auth coordinator and recovery, session broker, HTTP/control, + demand, revoke, E2EE, pairing, relay transport, runtime RPC failure, desktop startup, and serve + activation suite: passed, 17 files and 108 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,731,484 actual versus 7,779,720 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-desktop-shell-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-desktop-shell-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..99fb02efff8 --- /dev/null +++ b/tools/benchmarks/results/phase1-desktop-shell-startup-boundary-2026-07-29.md @@ -0,0 +1,178 @@ +# Phase 1 desktop-shell startup boundary — 2026-07-29 + +**Scope:** Move the app-ready menu, tray, dashboard-popout, macOS activation, +window-visibility notification, unread-badge, and startup-notification identities behind one +aggregate capability while retaining the minimal synchronous focus leaf required by the +pre-app-ready second-instance contract. + +## Result + +`src/main/index.ts` now performs one dynamic import of +`./startup/desktop-shell-startup-capability` after the retained browser, main-window, +terminal-runtime, and updater-runtime capabilities. It installs the returned object in a typed +owner before certificate handling, Store creation, runtime construction, menu registration, +terminal startup, serve branching, window creation, or runtime RPC start. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,761,675 | 3,697,673 | -64,002 | 801,485 | 785,736 | -15,749 | + +The preload and all renderer static graphs were byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 89,303 bytes and had SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; +their direct diff was empty. + +## Production importer audit + +Before this tranche, `index.ts` directly value-imported the following desktop-shell modules: + +- `menu/register-app-menu`: `registerAppMenu`, `rebuildAppMenu`, and + `getNextDefaultOnAppearanceSettingValue`. Its other production importers are the settings and + keybinding IPC handlers already reached through the deferred core-IPC registry. +- `tray/system-tray`: `createSystemTray`, `destroySystemTray`, + `setMacMenuBarIconVisible`, and `setTrayAttention`. Its other importers are app and + notification IPC; the former is behind core IPC, while the latter was also eagerly reached by + `index.ts` solely for startup notification registration. +- `window/dashboard-popout-window`: `zoomDashboardPopoutIfFocused`. Its other importers are the + retained deferred main-window capability and dashboard, terminal-preview, and clipboard IPC + graphs. +- `window/macos-app-activation`: `createMacAppActivationHandler`; `index.ts` was its sole + production importer. +- `window/focus-existing-window`: `focusExistingMainWindow`; dashboard IPC also imports the + narrower `safelyRevealWindow`. +- `window/main-window-visibility`: `notifyMainWindowBecameVisible`; notification IPC, + worktree-base-directory polling, and SSH relay sessions consume the same module-level listener + registry. +- `dock/unread-badge`: `setUnreadDockBadgeCount`; app IPC consumes the same module-level badge + state behind core IPC. + +The aggregate also returns the exact `triggerStartupNotificationRegistration` identity. Without +that addition, the eager `index.ts` notification import would continue pulling the tray and +window-visibility graph into the entry. Notification registration policy, sounds, permission +handling, and IPC channels remain in `ipc/notifications.ts`. + +## Pre-ready and lifecycle audit + +`focusExistingMainWindow` intentionally remains the only eager target leaf. The single-instance +lock is acquired before `app.whenReady`, and its synchronous callback calls +`requestDesktopActivation`; the desktop activation gate can immediately reach +`focusExistingWindow`. A pre-ready second-instance must still return `pending` synchronously via +the original focus function, while an already-ready process must synchronously reveal, restore, +focus, or recreate the window. Deferring that leaf would change this established contract. + +All other moved identities have no required pre-ready caller: + +1. At app readiness, the browser, main-window, terminal-runtime, and updater-runtime capabilities + are installed first. +2. The desktop-shell aggregate is loaded once, its exact object is installed in the owner, and the + macOS activation handler is constructed with the original `getWindow` and + `requestDesktopActivation` callback identities. +3. Certificate handling, Store and service construction, browser-session setup, runtime/RPC + wiring, and plugin startup follow. +4. Main i18n finishes before the exact menu registration function receives the original update, + reload, settings, setup-guide, crash-report, feature-tour, zoom, sidebar, appearance, and + keybinding callbacks. +5. Terminal startup completes before the same macOS activation handler is registered. Registration + remains before the headless-serve branch, so serve-to-desktop promotion can use it once the + existing activation gate becomes ready. +6. Desktop startup still loads core IPC before the synchronous `openMainWindow` call. Window and + runtime RPC startup remain parallel. + +The typed owner throws if a required post-ready consumer runs before installation. `will-quit` +uses the optional owner read because Electron can theoretically quit before readiness; its +pre-install behavior is the same observable no-op as destroying a never-created tray and clearing +an initially empty dock badge. Post-ready cleanup calls the exact shared functions. + +## Identity and behavior preservation + +The capability constructs no replacement service and owns no state. It returns the original +functions from their original modules, preserving: + +- the application menu's module-level last-registration options and rebuild identity; +- platform-specific menu labels, `CmdOrCtrl` accelerators, and modifier-click updater options; +- the tray singleton, attention latch, native-theme listener, dev badge, macOS status item, + Windows notification icon, and Windows quit latch behavior; +- the dashboard-popout singleton and focused-window zoom routing; +- the process-global main-window visibility listener set used by local polling and SSH relay + sessions; +- the unread dock-badge module state; +- main-window show/restore notification and tray-attention clearing on every recreation; +- settings-driven macOS menu-bar visibility; +- updater quit suppression, manual menu/tray update checks, and pending auto-updater setup; +- startup notification registration after the first window is shown and onboarding is complete; + and +- macOS dock activation, hidden-window restore, second-instance focusing, and serve promotion. + +The original platform checks remain inside the source modules: tray work remains macOS/Windows +only, dock badges remain macOS only, Linux tray creation remains a no-op, and macOS activation +uses the existing destroyed-window test. This tranche changes no path, workspace, Git, provider, +SSH, remote, WSL, folder-workspace, or Git 2.25 behavior and does not assume a local Git worktree. + +## Emitted chunks and dependency closure + +The retained build emits +`out/main/chunks/desktop-shell-startup-capability-DX4FEJ5N.js` at 1,640 raw / 604 gzip bytes with +SHA-256 `0c10aca2368870f2977683f4db50aeef56ffbc673ee2e50ddf285a14de7a75c9`. +Its eight direct static relative dependencies are: + +- `./tui-agent-config-DMkBaphp.js` +- `./worktree-id-D7UI5W7A.js` +- `./keybindings-dTghwn23.js` +- `../index.js` +- `./browser-url-ChZQOGgl.js` +- `./window-shortcut-policy-C9Dpprb5.js` +- `./register-app-menu-CbXEWIpV.js` +- `./dashboard-popout-window-D7hqkv8t.js` + +The `../index.js` edge is the bundler's expected shared-entry cycle. It preserves shared +main-window visibility state rather than creating a second registry. + +An Acorn AST walk of the capability closure visited 110 JavaScript files and validated 588 +literal relative import, export, dynamic-import, and require edges. A separate full `out/main` +scan checked all 120 JavaScript files and 634 relative edges. Every resolved target exists beneath +`out/main`; no edge escapes the emitted directory. The final `out/main/index.js` SHA-256 is +`12659e2bc9a122e5d9e62a34e05aa8edab6e5c51ef2606f63400c5d5995b6aed`; +the A entry SHA-256 was +`de4728ad2aa99b357b9ee5dbd4589f3d709908ab424fa45118bc08266bc63b9a`. + +## Budget + +The prior Electron-main raw budget was 3,809,911 bytes. Lowering only that budget by the exact +64,002-byte reduction produces 3,745,909 bytes and leaves exactly 48,236 bytes of headroom over +the 3,697,673-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and final B `pnpm run build:electron-vite`: passed; main transforms changed + from 1,987 to 1,989, preload remained 17 modules, and renderer remained 9,181 modules. +- Focused capability, owner, source-boundary, menu, tray, unread badge, dashboard popout, focus, + macOS activation, visibility, main-window create/attach, notification/app IPC, desktop ordering, + serve activation/promotion, updater boundary/quit state, window-all-closed, teardown deadline, + update handoff, and serve readiness coverage: 24 files / 300 passed and 1 skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,697,673 actual versus 3,745,909 budgeted + Electron-main bytes. +- `git diff --check`: passed with no whitespace errors. + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +## Remaining limitation + +The production build and closure scans validate emitted relative dependency resolution on this +macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and Windows; +cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-emulator-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-emulator-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..56ce2985eb1 --- /dev/null +++ b/tools/benchmarks/results/phase1-emulator-startup-boundary-2026-07-29.md @@ -0,0 +1,83 @@ +# Phase 1 emulator startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move `EmulatorBridge` construction out of the eager Electron main graph without + changing runtime contracts or initialization timing. + +## Result + +`src/main/index.ts` now awaits one dynamic import of +`./startup/emulator-startup-capability`, then attaches the returned bridge to +`OrcaRuntimeService` before the existing `services-initialized` milestone. Desktop and serve +IPC/RPC therefore continue to observe a fully attached bridge, with the same managed-helper +ownership and shutdown behavior. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 8,141,780 | 8,065,000 | -76,780 | 1,701,308 | 1,682,165 | -19,143 | + +The other static startup graphs were unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Boundary evidence + +The generated implementation chunk is +`out/main/chunks/emulator-startup-capability-B423ORHj.js` (77,929 raw bytes). The generated +main entry loads it with the packaged-relative specifier +`./chunks/emulator-startup-capability-B423ORHj.js`; the specifier is not absolute, contains no +parent traversal, and resolves beside `out/main/index.js` inside the packaged ASAR. + +`src/main/startup/emulator-startup-capability.ts` is the only production value importer of +`emulator-bridge`. Existing consumers remain type-only: + +- `src/main/runtime/orca-runtime.ts` +- `src/main/runtime/orca-runtime-emulator.ts` +- `src/main/emulator/emulator-availability.ts` +- `src/main/emulator/emulator-default-attach-device.ts` + +The source contract rejects a static `emulator-bridge` import or direct construction in +`src/main/index.ts`, and rejects attachment after `services-initialized`. The focused factory test +also confirms that the attached bridge retains both iOS and Android backends. + +## Budget + +The `electron-main` raw budget is 8,113,236 bytes. This lowers the prior budget by the measured +76,780-byte improvement and leaves 48,236 bytes (0.598%) of headroom over the 8,065,000-byte +entry. No other budget changed. + +## Validation + +- Focused emulator/runtime/startup suite: passed, 8 files and 76 tests: + `emulator-startup-capability`, `emulator-startup-boundary`, `desktop-startup-ordering`, + `serve-desktop-activation-wiring`, `startup-diagnostics`, `runtime-rpc-startup-failure`, + `emulator-bridge`, and `emulator-availability`. +- `pnpm run build:electron-vite`: passed with 1,955 main modules transformed, the relative + capability chunk emitted, and the two existing warnings below. +- `pnpm run check:electron-bundle-budgets`: passed at 8,065,000 actual versus 8,113,236 + budgeted main bytes. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the capability factory, and its two tests: + passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those four files plus + `config/electron-bundle-budgets.json`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `git diff --check`: passed with no whitespace errors. +- Targeted `pnpm exec oxfmt --check` on this report: passed. + +## Existing warnings and limitation + +The production build retained two existing CSS optimizer warnings and introduced no new warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The build proves packaged-relative chunk generation on this macOS worktree, but this tranche did +not run a packaged ASAR launch smoke test on macOS, Linux, or Windows. diff --git a/tools/benchmarks/results/phase1-keybinding-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-keybinding-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..db953e29167 --- /dev/null +++ b/tools/benchmarks/results/phase1-keybinding-startup-boundary-2026-07-29.md @@ -0,0 +1,124 @@ +# Phase 1 keybinding service startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `KeybindingService` startup construction out of the eager Electron main + graph while preserving its synchronous migration and one-shot cohort seed at the existing + startup point. + +## Result + +`src/main/index.ts` now type-imports `KeybindingService`, awaits the dynamic +`./startup/keybinding-service-startup-capability` import, and awaits +`createKeybindingServiceStartupCapability(...)` at the original construction site. The factory +passes the original options object directly to the constructor and returns that same live service +after the constructor has synchronously completed. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,946,692 | 7,934,074 | -12,618 | 1,659,337 | 1,656,699 | -2,638 | + +The other static startup graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Importer evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the +sole value importer and sole direct constructor of +`src/main/keybindings/keybinding-service.ts`. All production IPC consumers were already +type-only: + +- `src/main/ipc/keybindings.ts` +- `src/main/ipc/dashboard-popout.ts` +- `src/main/ipc/register-core-handlers.ts` + +After the edit, `src/main/startup/keybinding-service-startup-capability.ts` is the sole +production value importer and direct constructor. `src/main/index.ts` and all three IPC +consumers remain type-only. + +The focused source boundary test rejects an eager service value import or direct construction in +`src/main/index.ts`, requires exactly one awaited capability import and factory call, and fixes +construction before `browserManager.setSettingsResolver`, plugin service construction, and +`services-initialized`. + +## Preserved behavior and ordering + +The dynamic import and factory remain at the exact former constructor site. The constructor +therefore still completes before the browser settings resolver is installed and before any +observable readiness: + +- `homePath` still comes from `app.getPath('home')`, so the config path remains + `/.orca/keybindings.json`. +- No `platform` override is introduced, so `KeybindingService` still selects + `process.platform`. +- Legacy settings migration still runs synchronously before cohort seeding through the original + `getLegacyOverrides` callback. +- The one-shot tab-switch seed still reads `tabSwitchKeybindingSeed === 'pending'` and marks + `done` only through the original `store.updateSettings` callback after a successful seed. +- Constructor seed failures remain caught by the service and leave the cohort pending, so the + next launch retries. +- The returned instance remains the global live service used by browser-manager settings, + plugin keybindings, window and menu shortcut resolution, dashboard and core IPC handlers, both + headless serve and desktop startup paths, and the `openMainWindow` initialized guard. +- No readiness milestone, initializing state, serve return, desktop window-open ordering, or + later consumer was moved or changed. + +`keybinding-file.ts` and its existing max-lines suppression were not modified. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/keybinding-service-startup-capability-B-aIhhXd.js` (12,590 raw / 3,123 gzip +bytes). `out/main/index.js` loads it with +`require("./chunks/keybinding-service-startup-capability-B-aIhhXd.js")`; the specifier is +relative, contains no parent traversal, resolves to an emitted file under `out/main/chunks`, and +matches packaged-ASAR-relative lookup. + +The capability chunk's existing shared-keybinding dependency is also emitted with the relative +specifier `./keybindings-DPCRTERw.js`. That shared chunk remains statically reachable from other +main-process protocol consumers, while the service constructor and keybinding-file implementation +are absent from the eager main entry. + +## Budget + +The `electron-main` raw budget is 7,982,310 bytes. This lowers the prior 7,994,928-byte budget by +the exact measured 12,618-byte improvement and leaves 48,236 bytes (0.608%) of headroom over the +7,934,074-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,958 main modules; the post-edit build transformed 1,959 and emitted the deferred capability + chunk. +- Focused behavior and startup suite: passed, 16 files and 219 tests with one existing skip. + Coverage included the new factory and boundary tests, keybinding service and file migration, + one-shot seed and retry behavior, keybinding IPC, dashboard and core handlers, browser guest + shortcuts and settings, plugin keybindings, application menu shortcuts, dashboard windows, + desktop/serve startup ordering, startup diagnostics, and runtime RPC startup failure. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the capability, and its two tests: passed + with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,934,074 actual versus 7,982,310 + budgeted main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The build proves packaged-relative chunk generation on this macOS worktree, but this tranche did +not run a packaged ASAR launch smoke on macOS, Linux, or Windows. Cross-platform packaged launch +verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-main-i18n-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-main-i18n-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..cb9834eaa67 --- /dev/null +++ b/tools/benchmarks/results/phase1-main-i18n-startup-boundary-2026-07-29.md @@ -0,0 +1,143 @@ +# Phase 1 main-process i18n startup boundary audit — 2026-07-29 + +**Result:** Candidate rejected and reverted because the fresh production B entry was larger +than A. No main-process i18n capability, owner, source-boundary test, index qualification, prior +boundary assertion, or budget edit was retained. + +## Candidate scope + +The measured candidate moved only the three direct `src/main/index.ts` values from +`./i18n/main-i18n`: + +- `ensureMainI18n`; +- `setMainPluginLanguagePacks`; and +- `setMainUiLanguage`. + +It dynamically imported one `main-i18n-startup-capability` immediately after the retained +runtime-connectivity capability, returned the three original function identities, and installed +the result in a typed fail-closed owner. Initial initialization used the exact installed object; +the plugin content-pack callback used the owner and retained its original language-pack list, +settings language, and menu-rebuild expressions. + +The candidate added narrow capability, owner, and source-boundary coverage. It also temporarily +updated only the existing plugin-system boundary assertion from the direct +`setMainPluginLanguagePacks(` identifier to the owner-qualified identifier. That assertion +maintenance and every candidate source/test file were reverted after measurement. + +## Production importer and lifecycle audit + +The audit covered all production TypeScript occurrences beneath `src/main` and `src/shared`, +excluding tests and specs: + +- `src/main/index.ts` directly imports the three candidate values. +- `src/main/ipc/settings.ts` directly imports `setMainUiLanguage` and awaits it in the existing + settings-update handler. +- `src/main/menu/register-app-menu.ts` directly imports `translateMain` for application-menu + labels. +- `src/main/runtime/runtime-rpc-startup-failure.ts` directly imports `translateMain` for + startup-failure dialog copy and remains eagerly reachable from `index.ts`. +- `src/main/tray/system-tray.ts` directly imports `translateMain` for tray labels and tooltips. +- `src/main/window/createMainWindow.ts` directly imports `translateMain` for window-owned + notification copy. + +All importers resolve the same cached `main-i18n` module and therefore the same +`i18next.createInstance()` result, `initialized` flag, plugin-language-pack array, registered +language set, lazy backend, and locale-loader table. Moving only the three `index.ts` references +cannot remove `main-i18n` from the eager entry because the preserved runtime-RPC failure module +still brings `translateMain` into that graph. + +The candidate kept `translateMain`, settings IPC, menus, dialogs, tray, and window consumers at +their existing owners. It also preserved: + +- one i18next instance and module cache; +- lazy `es`, `ja`, `ko`, and `zh` catalog imports; +- English default-value fallbacks and pseudo-localization; +- plugin language-pack replacement and resource-bundle registration; +- UI-language selection, system-locale fallback, and plugin-locale fallback; +- plugin content-pack change -> language change -> menu rebuild behavior; +- `services-initialized` -> `ensureMainI18n` -> initial language -> + `i18n-ready` -> menu registration ordering; and +- existing promise rejection/error propagation. + +No pre-ready bootstrap/profile, platform, Store/account, runtime, mobile, relay, shutdown, +macOS/Linux/Windows, WSL, SSH/remote, folder-workspace, Git/provider, or plugin-provider behavior +was moved. + +## Fresh A/B measurement + +| Surface | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | +| ------------- | --------: | --------: | ---------: | ------: | ------: | ----------: | +| Electron main | 3,410,984 | 3,411,700 | **+716** | 720,505 | 720,607 | **+102** | + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-main-i18n-a.298cGW` +- B artifact: `/tmp/orca-main-i18n-b.Tkjj42` +- A transformed 2,001 main, 17 preload, and 9,181 renderer modules. +- B transformed 2,003 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry SHA-256: + +- A: + `edc16e8482159539251fe9e26cc62bbf3391f3e244035cbf62c1860b093104d5` +- B: + `601b57173e7bddbf6867302d6b951d9be1939c057747279c1ba82e206a5f99bb` + +The A and B non-main manifests each contain exactly 786 rows and are byte-identical. Their +SHA-256 is +`f72f00efd1bc891383c4664090bfcea870746c0e8de2e84a8dfa1c2f09b19da2`. + +The A main manifest contains 144 rows with SHA-256 +`d3397a4b92ea2f1e411fd1e26c715bddc075510296b6610d49bec0d776c8d611`. +The B main manifest contains 145 rows with SHA-256 +`03e1abfbe95cfd0d305ada11b2d79e3acc39758bf0c67c5784ae5a6852270fba`. + +## Candidate emitted closure + +The rejected B emitted +`out/main/chunks/main-i18n-startup-capability-B7xJCZeI.js` at 355 raw / 184 gzip bytes with +SHA-256 +`6d7689a27183398f914191228506fcc004f36ed671aafe8c1e969a882164124a`. +Its sole direct literal relative edge was `../index.js`: the bundler kept `main-i18n` in the +entry and emitted only an indirection chunk back to that existing graph. + +An inclusive Acorn AST walk followed literal relative import, export, dynamic-import, and +`require` edges. The candidate capability closure visited 135 JavaScript files and validated +733 edges. The complete B emitted-main scan visited 145 JavaScript files and validated 783 +edges. Every target existed beneath `out/main`; no edge escaped the emitted directory. + +After reverting, a fresh production build restored the accepted entry byte-for-byte at +3,410,984 raw / 720,505 gzip with the A SHA above. Its complete emitted-main scan again visited +144 JavaScript files and validated 781 literal relative edges beneath `out/main`. + +## Budget consequence + +The accepted budget remains unchanged at 3,459,220 bytes: + +`3,459,220 - 3,410,984 = 48,236` + +Had B been retained, headroom would have fallen to 47,520 bytes, violating both the +raw-reduction requirement and the exact-headroom contract. No budget file change was made by +this tranche. + +## Tests and checks + +- Candidate capability/owner/source-boundary plus inherited plugin-system boundary: + 4 files / 12 tests passed before measurement. +- Restored focused i18n, settings IPC, menu, runtime-RPC failure dialog, plugin-system, + desktop startup/shell, and main-window suite: 10 files / 175 tests passed and 1 skipped. +- A, rejected B, and restored production `pnpm run build:electron-vite`: passed. +- The restored entry exactly matches the independently accepted raw, gzip, and SHA baseline. + +The final typecheck, lint, format, max-lines, bundle-budget, and diff checks apply to the +restored worktree plus this evidence report; their results are recorded in the worker +completion. + +## Residual packaged-ASAR limitation + +The production builds and inclusive closure scans validate emitted relative resolution on this +macOS worktree. Packaged-ASAR launch smokes were not run on macOS, Linux, or Windows and remain +unresolved. diff --git a/tools/benchmarks/results/phase1-main-window-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-main-window-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..9ed9364e266 --- /dev/null +++ b/tools/benchmarks/results/phase1-main-window-startup-boundary-2026-07-29.md @@ -0,0 +1,168 @@ +# Phase 1 main-window startup boundary — 2026-07-29 + +**Scope:** Move the concrete main-window creation, loading, service attachment, and updater setup +graph behind one app-ready capability while preserving synchronous window creation, exact function +identity, desktop/serve activation, and all existing window lifecycle policy. + +## Result + +`src/main/index.ts` no longer value-imports `window/createMainWindow` or +`window/attach-main-window-services`. It awaits one +`./startup/main-window-startup-capability` immediately after browser-kernel initialization, then +installs the returned four exact function identities in a small typed owner used by the +predeclared window, updater, and crash-recovery functions. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,396,073 | 4,001,071 | -395,002 | 934,292 | 849,834 | -84,458 | + +The preload and every renderer static graph were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 87,731 bytes and had SHA-256 +`68ba944c45d80c14c538180894eade74a3897e120fa0bf6a89fbb60500a3ab01`; a direct diff was empty. + +## Importer and readiness audit + +Before this tranche: + +- `index.ts` eagerly imported `createMainWindow` and `loadMainWindow` from + `window/createMainWindow.ts`. +- `index.ts` eagerly imported `attachMainWindowServices` and + `ensureAutoUpdaterConfigured` from `window/attach-main-window-services.ts`. +- `createMainWindow.ts` also calls its own `loadMainWindow` for the existing reload and recovery + paths. +- `attach-main-window-services.ts` calls its own `ensureAutoUpdaterConfigured` from the existing + update-check path. +- `ipc/register-core-handlers.ts` imports `registerUpdaterHandlers` from + `attach-main-window-services.ts`, but that importer is already reached only through the deferred + core-IPC registry. + +No required consumer runs before `app.whenReady`: + +- Main-window creation is reached only by desktop startup, macOS activation, tray/menu actions, or + serve-to-desktop promotion after readiness. +- Manual updater checks are user-initiated through menu/tray wiring created with the first window. +- Renderer crash recovery can only run after a window has been created. +- Headless serve does not create or attach a main window unless the existing desktop-promotion + gate is deliberately activated. + +After the edit, the aggregate capability is the sole production importer of +`createMainWindow.ts`. It and the already-deferred core-IPC graph are the only production importers +of `attach-main-window-services.ts`. `index.ts` has exactly one dynamic aggregate import and no +eager path to either target module. + +## Capability and identity contract + +`createMainWindowStartupCapability` returns: + +- the original `createMainWindow` identity; +- the original `loadMainWindow` identity; +- the original `attachMainWindowServices` identity; and +- the original `ensureAutoUpdaterConfigured` identity. + +It does not wrap, reconstruct, or move policy out of either window module. The typed +`main-window-startup-owner` holds the exact returned object. Access before installation throws +`Main-window capability must be initialized before use`, so a premature activation cannot create +a partially configured window. + +The owner is installed immediately after the browser-kernel capability and before browser-manager +assignment, watchdog setup, certificate-error registration, `app.setName`, Store/runtime startup, +activation handler registration, serve branching, or any window creation. This keeps the +capability available for ordinary desktop startup and serve-to-desktop promotion before the +activation gate settles. + +## Window, updater, and lifecycle order + +The retained order is: + +1. `app.whenReady` resolves and records `app-ready`. +2. The browser-kernel capability loads and links browser/session dependencies. +3. The main-window capability loads and is installed in the fail-closed owner. +4. Existing watchdog, certificate, app identity, profile, Store, account, runtime, browser, + plugin, automation, IPC, RPC, and activation setup continues unchanged. +5. The non-headless desktop path awaits the deferred core-IPC registry. +6. Desktop startup still invokes synchronous `openMainWindow()` and `runtimeRpc.start()` inside + the same `Promise.all`. +7. `openMainWindow` synchronously resolves the four identities, validates every existing service + guard, and constructs the window with the same Store and options, including `deferLoad: true` + and the same quit, reload, crash, recovery, keybinding, and title callbacks. +8. The `did-finish-load` listener is attached before core IPC registration. Core IPC still + receives the same Store, runtime, stats, usage, account, rate-limit, automation, plugin, + marketplace, lifecycle, AI Vault, relaunch, and auth callback identities. +9. Automation web-contents attachment/start remains before `attachMainWindowServices`; attached + services retain the same runtime-home, auth, PTY, reload, update, worktree, updater, menu, and + lifecycle inputs. +10. TCC, rate limits, window-close cleanup, agent-status listeners, worktree events, and + `loadMainWindow(window)` retain their existing order. + +Manual update checks still call the exact `ensureAutoUpdaterConfigured` before +`checkForUpdatesFromMenu`. Manual renderer recovery still uses the exact `loadMainWindow`. +Window recreation reuses the installed capability and creates no duplicate functions or owners. +Tray reopen, macOS dock activation, menu/settings actions, updater pending configuration, and +serve promotion retain their existing gates. + +BrowserManager initialization and partition policy remain in the earlier browser-kernel boundary. +Windows user-data ACL repair remains immediately before the BrowserWindow constructor. No path, +SSH/remote, folder-workspace, Git/provider, Git 2.25, or macOS/Linux/Windows policy moved into the +capability. + +## Emitted chunks and dependency closure + +The retained build emits: + +- `out/main/chunks/main-window-startup-capability-Ua_UTW_Q.js`: 35,755 raw / 8,572 gzip bytes, + SHA-256 `ad7a7b1a4316106aae35cacfb497845f54d339111c83eb372b878b9eb0727253`; +- `out/main/chunks/attach-main-window-services-YMLmQZvj.js`: 260,003 raw / 52,657 gzip bytes, + SHA-256 `d9e01e56b321c7ca078e331cb360a4ca8e93f65ea36027d9cb86cefd2610cd4a`. + +The retained `out/main/index.js` SHA-256 is +`c84d6cce378003b1cda42b6f29c6433f34fde9d3e6b6f8bcaf742877bf52abe5`. + +An AST scan found 30 direct static relative edges from the capability chunk. Its full reachable +closure visited 98 JavaScript files and validated 482 relative edges. A separate complete +`out/main` scan checked 108 JavaScript files and 527 literal relative import, export, and require +edges. Every target exists beneath `out/main`; none escape the emitted main directory. The +capability's expected shared-module edge back to `../index.js` remains a bundler-generated cycle, +with `index.js` dynamically entering the capability. + +## Budget + +The prior Electron-main raw budget was 4,444,309 bytes. Lowering only that budget by the exact +395,002-byte raw reduction produces 4,049,307 bytes and leaves exactly 48,236 bytes of headroom +over the 4,001,071-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B builds: passed. +- Focused capability, owner, boundary, create/attach, app menu, system tray and tray icons, + updater, update watchdog, macOS activation, serve activation/readiness/stdout, runtime-RPC + startup failure, quit policy, Windows ACL, and core-IPC coverage: 26 files / 385 passed and 1 + skipped. +- Focused capability, owner, boundary, core-IPC, and desktop startup-order subset: 5 files / 21 + tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,001,071 actual versus 4,049,307 budgeted + Electron-main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-offscreen-browser-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-offscreen-browser-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..ca70a7b8594 --- /dev/null +++ b/tools/benchmarks/results/phase1-offscreen-browser-startup-boundary-2026-07-29.md @@ -0,0 +1,143 @@ +# Phase 1 OffscreenBrowserBackend startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only serve-mode `OffscreenBrowserBackend` construction out of the eager Electron + main graph while preserving display gating, runtime identity, headless graph and RPC ordering, + serve readiness, desktop behavior, and committed teardown. + +## Result + +`src/main/index.ts` no longer imports `OffscreenBrowserBackend` eagerly. Only inside the existing +`serveOptions` and `headlessBrowserDisplayAvailable` branch, it awaits +`./startup/offscreen-browser-startup-capability` and calls +`attachOffscreenBrowserStartupCapability(runtime, browserManager)`. The capability constructs +with the original `browserManager`, attaches that same live instance through +`runtime.setOffscreenBrowserBackend`, and returns it. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,731,484 | 7,728,795 | -2,689 | 1,616,703 | 1,615,903 | -800 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison also matched all 786 emitted files outside `out/main`, covering +the complete preload and renderer output. + +## Importer and constructor evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of +`src/main/browser/offscreen-browser-backend.ts`. Generic consumers in `orca-runtime.ts` and +`orca-runtime-browser.ts` depend only on the existing `BrowserBackend` contract and runtime +getter/setter; they neither import nor construct the concrete backend. + +After the edit, `src/main/startup/offscreen-browser-startup-capability.ts` is the sole production +value importer and sole direct constructor. `src/main/index.ts` has exactly one dynamic +capability import and no static backend import or direct construction. The runtime's generic +`setOffscreenBrowserBackend` and `getOffscreenBrowserBackend` APIs are unchanged. + +The concrete backend implementation is untouched. It retains the original offscreen +`BrowserWindow`, default viewport, profile/default partition selection, shared guest web +preferences, sandbox/context-isolation/node-integration settings, guest registration and +unregistration, asynchronous URL loading, main-frame failure handling, redirect-abort handling, +page lookup, close behavior, and `destroyAll` cleanup. + +## Preserved serve, display, runtime, and teardown behavior + +The display probe remains synchronous at the same early single-instance-lock timing: +`ensureVirtualDisplayForHeadlessServe({ isServeMode })` still determines +`headlessBrowserDisplayAvailable` before Electron readiness. Its behavior is unchanged: + +- macOS and other non-Linux platforms report display support without spawning Xvfb. +- Desktop Linux does not start Xvfb and reports the serve-only offscreen path unavailable. +- Linux serve reuses an explicit `DISPLAY` or a verified live `:99` server. +- Missing Xvfb leaves browser panes unavailable without blocking the rest of serve. +- A stale socket is removed before starting Xvfb; startup timeout tears the attempted display + down and reports unavailable. +- Linux serve retains the same Chromium software-rendering and shared-memory flags. + +Backend loading and attachment occur only when both existing gates pass: + +1. `serveOptions` is non-null. +2. `headlessBrowserDisplayAvailable` is true. + +The capability is therefore absent from desktop startup and from Linux serve without a usable +display. Construction still happens after headless PTY registration and before +`runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, ...)`. Graph sync still precedes +`runtimeRpc.start()`, which still precedes serve desktop-activation settlement, signal +installation, CLI/dispatcher work, automation startup, and `printServeReady(serveOptions)`. + +The original `runtime` and `browserManager` objects are passed by identity. The attached backend +therefore remains the instance used for headless capability advertisement, tab creation and +closing, WebContents lookup, browser screencast routing, mobile session hydration, and +certificate-trust capability publication. The SSH browser path continues to use the hosting +serve runtime's generic offscreen backend; no SSH routing or folder-workspace resolution changed. + +Normal desktop startup still opens its renderer window and starts runtime RPC after the +serve-only early return, without importing or attaching the offscreen backend. Closing +main-process offscreen windows still cannot terminate a serve owner through the unchanged +window-all-closed policy. The committed `will-quit` path still calls +`runtime?.getOffscreenBrowserBackend()?.destroyAll?.()` before browser-manager listener cleanup, +PTY cleanup, and RPC teardown. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/offscreen-browser-startup-capability-D7vO2LoR.js` (3,612 raw / 1,333 gzip +bytes). `out/main/index.js` loads it through +`./chunks/offscreen-browser-startup-capability-D7vO2LoR.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./keybindings-BUptVVER.js` +- `./tui-agent-config-CgQCoDXB.js` +- `../index.js` + +These paths match packaged-relative CommonJS resolution. + +## Budget + +The prior `electron-main` raw budget was 7,779,720 bytes. Lowering it by the exact measured +2,689-byte improvement produces a new budget of 7,777,031 bytes and leaves exactly 48,236 bytes +(0.624%) of headroom over the 7,728,795-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,969 main modules; the post-edit build transformed 1,970. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused offscreen web-preferences, browser-manager/grab, browser-session registry/persistence + and startup, full runtime plus runtime browser headless routing, runtime startup failure, + display/Xvfb, window-all-closed, desktop startup, serve activation/readiness, and new + capability/boundary suite: passed, 17 files with 1,117 tests passed and one skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, report, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,728,795 actual versus 7,777,031 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-opencode-usage-store-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-opencode-usage-store-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..27ca7be4f3e --- /dev/null +++ b/tools/benchmarks/results/phase1-opencode-usage-store-startup-boundary-2026-07-29.md @@ -0,0 +1,134 @@ +# Phase 1 OpenCodeUsageStore startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `OpenCodeUsageStore` construction out of the eager Electron main graph + while preserving synchronous OpenCode usage-path capture, store identity, scan behavior, and + every existing consumer. + +## Result + +`src/main/index.ts` now type-imports `OpenCodeUsageStore`, eagerly imports only +`initOpenCodeUsagePath` from the concrete +`src/main/opencode-usage/opencode-usage-file-path.ts` module, and awaits the dynamic +`./startup/opencode-usage-store-startup-capability` import at the original construction site. The +factory calls only `new OpenCodeUsageStore(store)` with the original `Store` object and returns +that same live instance. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,904,344 | 7,891,025 | -13,319 | 1,651,679 | 1,649,850 | -1,829 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison also matched all 786 emitted files under `out/preload` and +`out/renderer`. + +## Importer and constructor evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of `src/main/opencode-usage/store.ts`. +`src/main/ipc/opencode-usage.ts` and `src/main/ipc/register-core-handlers.ts` were type-only +consumers. + +After the edit, +`src/main/startup/opencode-usage-store-startup-capability.ts` is the sole production value +importer and direct constructor. `src/main/index.ts` and both IPC consumers are type-only. +`store.ts` retains a compatibility re-export of `initOpenCodeUsagePath`. + +Only path state, synchronous initialization, and the cached fallback getter moved out of +`store.ts`. Persistence schema version 2, normalization, atomic writes, scan policy, query +semantics, and public APIs are unchanged. + +## Preserved path timing, ordering, and behavior + +`initOpenCodeUsagePath()` remains a direct synchronous call in the existing +single-instance-lock block: + +1. It still runs immediately after `initDataPath()`. +2. It still captures `app.getPath('userData')` before `app.whenReady()` and + `app.setName(devInstanceIdentity.appName)`. +3. It still resolves exactly `/orca-opencode-usage.json` with `path.join`. +4. The fallback getter still captures once on first access if startup initialization was skipped. + +The cross-platform path-state test changes the mocked late Electron user-data path after +initialization and proves reads stay on the early captured path. It also proves fallback capture +is cached. Both expectations use `path.join`, including on Windows. + +The awaited capability import and factory replace `new OpenCodeUsageStore(store)` at its exact +former services point: after the Claude and Codex usage stores, before rate limits and all +consumers, and before the `services-initialized` milestone. Construction therefore still +finishes before any main-window IPC registration or readiness exposure. + +The same singleton remains protected by the existing `openMainWindow` initialization guard and +is passed unchanged to `registerCoreHandlers`, which passes it unchanged to +`registerOpenCodeUsageHandlers`. IPC channel names, argument contracts, and store method routing +did not change. + +The store still receives the original persistence `Store` object by identity. Its existing +`loadKnownUsageWorktreesByRepo(this.store)` path continues to support folder workspaces and git +worktrees, while the untouched scanner retains local, WSL, SSH, and remote-host OpenCode usage +discovery, database ownership deduplication, scan enablement, refresh throttling, and persisted +state behavior. No Claude usage, Codex usage, account, rate-limit, renderer, or lifecycle +contract changed. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/opencode-usage-store-startup-capability-DnggxFSX.js` (12,381 raw / 3,145 gzip +bytes). `out/main/index.js` loads it through +`./chunks/opencode-usage-store-startup-capability-DnggxFSX.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved and confirmed to exist under +`out/main`: + +- `./chunk-BTjIgr6M.js` +- `./worktree-id-B3lEXLSJ.js` +- `./wsl-BeNFUvEz.js` +- `./schema-helpers-BaufHnZv.js` +- `../index.js` + +These paths match packaged-relative module resolution. + +## Budget + +The prior `electron-main` raw budget was 7,952,580 bytes. Lowering it by the exact measured +13,319-byte improvement produces a new budget of 7,939,261 bytes and leaves 48,236 bytes +(0.611%) of headroom over the 7,891,025-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,962 main modules; the post-edit build transformed 1,964. Both transformed 17 preload modules + and 9,181 renderer modules. +- Focused OpenCode store, scanner, Windows data-directory, usage-worktree metadata and + canonicalization, core-handler, capability, source-boundary, desktop/serve startup, and runtime + startup-failure suite: passed, 13 files and 74 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,891,025 actual versus 7,939,261 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-pinned-tab-close-dialog-lazy-boundary-2026-07-30.md b/tools/benchmarks/results/phase1-pinned-tab-close-dialog-lazy-boundary-2026-07-30.md new file mode 100644 index 00000000000..8886ff5d2fb --- /dev/null +++ b/tools/benchmarks/results/phase1-pinned-tab-close-dialog-lazy-boundary-2026-07-30.md @@ -0,0 +1,192 @@ +# Phase 1 pinned-tab close-dialog lazy boundary + +Date: 2026-07-30 + +Outcome: retained. A fresh production A/B reduced the main-window static renderer +closure by 2,481 raw bytes without increasing popout/web raw size, changing any +renderer JS/CSS count, or changing any Electron main/preload file. + +## Production audit and retained boundary + +Before editing, the only production component importer was `App.tsx`, which mounted +`PinnedTabCloseDialog` after `SkillFreshnessNudge` and immediately before the +last-rendered custom `WindowControls`. The retained host occupies that exact location. + +The complete state and close-path audit covered: + +- `store/slices/pinned-tab-close-confirm.ts`: owns the durable + `pinnedTabCloseConfirm` request, queued requests, 350 ms inter-request action + guard, advance-before-callback ordering, confirm callback, and optional cancel + callback. +- `store/pinned-tab-close-guard.ts`: routes guarded tab closes into the request + store and respects `confirmClosePinnedTab`. +- `components/terminal/terminal-tab-actions.ts`: routes terminal and kill-all close + paths through the same store. +- `hooks/useIpcEvents.ts`: routes native and CLI browser/workspace close requests + through the same store and preserves confirm/cancel replies. +- `components/terminal-pane/terminal-parked-tab-watchers.ts`: preserves buffered + exit ownership when a pinned close is canceled or queued. +- `TerminalPane.tsx`: retains the last-pane pinned preference check and existing + terminal-process close behavior. +- `GeneralPane.tsx`: remains the settings owner for the pinned-tab preference. +- `PinnedTabCloseDialog.tsx`: remains the sole checkbox, confirm/dismiss, setting + update, focus, copy, and request-reset surface. + +The retained eager `PinnedTabCloseDialogHost` imports only React, the root store, +`lazyWithRetry`, and the already-retained Settings module loader. It subscribes to +`state.pinnedTabCloseConfirm !== null`, initializes its permanent mount latch from +that durable snapshot (preserving a request that predates host subscription), and +sets the latch once on the first request. It then remains mounted after dismiss or +confirm, so the existing dialog sees every subsequent request through the same root +store instance and preserves its request-identity checkbox reset. + +The lazy surface uses reload key `pinned-tab-close-dialog` and a null Suspense +fallback. It reuses the existing Settings dynamic entry through +`loadSettingsModule`; `Settings.tsx` re-exports the existing dialog. This avoids a +new dynamic entry and keeps App's 202 static imports, 45 dynamic imports, and every +static JS/CSS count unchanged. + +No visual code, tokens, layout, focus behavior, platform shortcut, terminal +routing, SSH/runtime behavior, provider behavior, or folder-workspace behavior +changed. + +## Source and emitted reachability + +Production A: + +`index.html -> assets/App-Ci_esR-V.js -> PinnedTabCloseDialog` + +The component-bearing `"Close pinned tab?"` implementation was in the eager +`assets/App-Ci_esR-V.js` chunk, which was 1,553,772 raw bytes. + +Production B: + +`index.html -> assets/App-xTNBNFEg.js -> PinnedTabCloseDialogHost` + +On the first non-null request: + +`PinnedTabCloseDialogHost -> loadSettingsModule -> assets/Settings-Ct_y93Cg.js -> PinnedTabCloseDialog` + +The eager App chunk is 1,551,291 raw bytes. The dialog implementation is no longer +in it and is emitted in the pre-existing Settings dynamic entry. The Settings +entry grows only on the lazy side and is not part of the main-window static +closure. + +## Fresh production A/B + +Command for both builds: + +```text +pnpm run build:electron-vite +``` + +Archived artifacts: + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A: `/tmp/orca-pinned-tab-close-dialog-a.vSX6Ku` +- B: `/tmp/orca-pinned-tab-close-dialog-b.nZidJE` + +| Entry | A raw | A gzip | A JS/CSS | B raw | B gzip | B JS/CSS | Raw delta | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Electron main entry | 776,873 | 174,092 | 1/0 | 776,873 | 174,092 | 1/0 | 0 | +| Electron preload entry | 130,798 | 20,642 | 1/0 | 130,798 | 20,642 | 1/0 | 0 | +| Main renderer static closure | 8,419,021 | 1,878,382 | 292/2 | 8,416,540 | 1,877,823 | 292/2 | -2,481 | +| Popout renderer static closure | 4,507,253 | 984,615 | 77/2 | 4,507,253 | 984,615 | 77/2 | 0 | +| Web renderer static closure | 4,360,652 | 928,352 | 33/1 | 4,360,652 | 928,355 | 33/1 | 0 | + +The closure gzip figures sum each independently gzipped emitted file, matching the +checked-in reporter. Web gzip changed by three bytes because its preload map +contains changed hashed App filenames; its raw size and JS/CSS counts are exactly +unchanged. + +Entry and closure SHA-256 evidence: + +| Entry | A emitted file / SHA-256 | B emitted file / SHA-256 | A closure-manifest SHA-256 | B closure-manifest SHA-256 | +| --- | --- | --- | --- | --- | +| Main renderer | `assets/index-BA5SMP-t.js` / `de8e663ddf2221774a5ca15e060077fd7fcc5a62bcc81c962608eb15faa0d964` | `assets/index-Bo3thIVg.js` / `75b6a5ab6613febe862c593824ed754febb465cc7fbfc95782c7671a86991cdc` | `319b1e52def92f4b5cffb46e3ec1b51ab741835f519360e0c6b8bb401ab9592d` | `1704e50a0e37f3d56b7f4700e649e06f88670c7e59a7f1eb7a4ef960dede02f5` | +| Popout | `assets/popout-BD9-2Cja.js` / `1ae8a4ad2c961bb5fb3be2f515b0335576526b0cd1b409ebc66f1d9e60ded74a` | identical | `e20bb9bfab534fcbe64f1d82ebe3d27c5a39c3158e803511ee6967a042371c06` | identical | +| Web | `assets/web-8LsiV0SA.js` / `87e5081c86f08716e735031827995748574abe24d2ae09a0bd2008d2c1021de9` | `assets/web-BbxUr5FJ.js` / `4b542c49f858523dda96d95240fe4996b4b0701cc784e1b8a7b670114660cb51` | `8846b5c61f38b60ee80db3482ba018d12e37ae3f994de1f3ccb7cdeef8fa7949` | `be8b408bf16e356926fdb1e2c658be36671bc3ae5dc4677d412a56178d41e686` | + +Electron identities: + +- Main `out/main/index.js` A/B SHA-256: + `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd`. +- Preload `out/preload/index.js` A/B SHA-256: + `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f`. +- All 184 files under `out/main` were byte-for-byte identical. Both sorted + path/size/SHA manifests hash to + `907325898b9e2dcdbd572ca60c10d4595f656933b070566c9cea5050c7157cb8`. +- The one preload file was byte-for-byte identical. Both sorted manifests hash to + `6533a865267aa457293cf183850e1480898704395f4431682703824557c84d97`. +- A renderer manifest: 403,000 bytes, + `dc7342d1664bca2c5f42074479a81e7670655adde174c65718571c5311667123`. +- B renderer manifest: 403,000 bytes, + `92ad617f8c8aee6d4c4a23622f524266cbe9c93b3fe65544d1acb833dc3f0d67`. +- Both renderer trees contained 786 emitted files plus the manifest. + +## Inclusive manifest and Acorn validation + +Both A and B produced: + +- 778 manifest records and 778 referenced emitted targets; +- 6,460 manifest import/dynamic-import edges; +- 697 JavaScript targets parsed by Acorn using latest ECMAScript module syntax; +- 6,491 AST module edges; +- 6,489 literal relative emitted edges; +- zero missing, out-of-root, or unparsable targets. + +The validation included manifest imports, dynamic imports, CSS/assets, emitted +static imports, re-exports, and literal dynamic imports. + +## Budgets + +Only `renderer-index.maxRawBytes` was reduced, from 8,468,828 to 8,466,347, +exactly matching the retained 2,481-byte reduction. B preserves the prior 49,807 +bytes of main-renderer raw headroom. Renderer file-count budgets were unchanged. + +Electron main remains 776,873 raw against 825,109, exactly 48,236 bytes of +headroom. + +## Behavior and quality gates + +- Focused dialog/host/store/terminal-close Vitest: 6 files, 56 tests passed. + - no lazy load while the request is null; + - request present before host subscription; + - null first-load fallback; + - one lazy load and one mounted dialog instance; + - dismiss then reopen preserves the mounted instance; + - dialog checkbox resets for the next request; + - confirm, dismiss, and don't-ask preference behavior; + - queued store requests and inter-request guard; + - terminal close and kill-all pinned guards. +- Native/CLI IPC and parked-terminal watcher Vitest: 2 files, 144 tests passed, + including pinned confirm/cancel replies and buffered-exit ownership. +- `pnpm run typecheck:web`: passed. +- Targeted `oxlint --deny-warnings`: passed. +- Targeted `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered + suppressions and no new bypass. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +No max-lines disable or budget increase was added. + +## Files in this tranche + +- `config/electron-bundle-budgets.json` +- `src/renderer/src/App.tsx` +- `src/renderer/src/components/settings/Settings.tsx` +- `src/renderer/src/components/terminal-pane/PinnedTabCloseDialogHost.tsx` +- `src/renderer/src/components/terminal-pane/pinned-tab-close-dialog-lazy-boundary.test.tsx` +- `tools/benchmarks/results/phase1-pinned-tab-close-dialog-lazy-boundary-2026-07-30.md` + +## Residual limits + +No packaged macOS, Linux, or Windows launch was performed. Packaged ASAR lazy +loading, real cross-platform focus/keyboard behavior, SSH/runtime latency, and a +real IPC-originated pinned close remain unvalidated in this tranche; the evidence +is production-build, static-closure, source audit, type/lint/format, and focused +behavior coverage. diff --git a/tools/benchmarks/results/phase1-plugin-system-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-plugin-system-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..58f7793fb94 --- /dev/null +++ b/tools/benchmarks/results/phase1-plugin-system-startup-boundary-2026-07-29.md @@ -0,0 +1,203 @@ +# Phase 1 plugin-system startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move the five-class plugin kernel behind one aggregate startup capability while + preserving the kill-list barrier, platform path resolution, service identity, plugin policy, + IPC/runtime wiring, initialization triggers, and shutdown. + +## Result + +`src/main/index.ts` now type-imports `PluginKillListService`, `PluginMarketplaceService`, +`PluginMarketplaceInstaller`, `PluginService`, and `PluginBundledBootstrapCoordinator`. At the +original plugin construction point it awaits exactly one +`./startup/plugin-system-startup-capability` import and one aggregate factory. The returned live +instances are assigned to the existing globals before any listener, RPC, initialization, seed, +bootstrap, event, IPC, window, or teardown consumer runs. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,331,492 | 7,225,280 | -106,212 | 1,539,032 | 1,514,823 | -24,209 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison matched all 786 emitted files outside `out/main`, covering the +complete preload and renderer output. + +## Importer and constructor audit + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the only +startup value importer and only direct constructor site for all five classes. Other production +consumers use type-only imports: + +- `PluginService`: plugin enablement, list projection, approved ephemeral-VM recipes, core/plugin + IPC, marketplace IPC, ephemeral-VM IPC, and runtime RPC. +- `PluginMarketplaceService`: marketplace installer and marketplace IPC. +- `PluginMarketplaceInstaller`: marketplace IPC. +- `PluginKillListService` and `PluginBundledBootstrapCoordinator`: no production consumer outside + the implementation module and `index.ts`. + +The existing plugin IPC graph is deliberately eager through `registerCoreHandlers`, +`ipc/plugins.ts`, `ipc/plugin-marketplaces.ts`, `ipc/ephemeral-vm.ts`, and runtime RPC method +registration. That graph already owns schemas and client contracts, so moving IPC registration +would have widened the change and altered readiness. The retained seam moves only the concrete +kernel constructors and their startup-only data-dir, host-entry, bundled-root, and normalization +helpers. + +After the edit, `src/main/startup/plugin-system-startup-capability.ts` is the sole production +constructor site and value importer for the five classes. `index.ts` has one aggregate dynamic +import, one factory call, no direct plugin-kernel constructor, and no eager import of +`plugin-discovery`, `plugin-bundled-bootstrap`, `plugin-host-process`, or the plugin consent-state +normalizers. + +## Aggregate interface and exact order + +The capability accepts one narrow platform description: + +- canonical current `userDataPath`; +- `hostVersion`; +- `appPath`; +- `resourcesPath`; +- `isPackaged`. + +It also accepts live composition-root bindings for the selected plugin settings, keybindings, +kill-list entry/reason lookups, and plugin refresh. These are policies and mutable lifecycle +references owned by `index.ts`, not five constructor argument bags. In particular, the +kill-list and refresh closures still resolve through the globals, so committed quit clearing +retains its prior fail-closed/no-op behavior. + +The focused capability test proves the exact sequence: + +1. construct `PluginKillListService`; +2. await `initialize()` as a hard barrier; +3. construct `PluginMarketplaceService`; +4. construct `PluginMarketplaceInstaller` with the same marketplace instance; +5. construct `PluginService`; +6. construct `PluginBundledBootstrapCoordinator`. + +The same platform strings and callback objects reach their original consumers by identity. The +factory performs no event wiring or initialization beyond the pre-existing awaited kill-list +load. After it returns, `index.ts` assigns kill list, marketplace, installer, plugin service, and +bundled bootstrap in that order, before restoring the original listeners and triggers. + +## Preserved behavior, paths, and side effects + +No plugin service implementation changed. + +- Kill-list construction still creates the same file store and fetcher. The awaited initialize + barrier still loads the cached snapshot, fails open on an invalid cache, and completes before + marketplace, installer, plugin service, or bundled bootstrap construction. Refresh + serialization, future/rollback rejection, atomic persistence, and change notifications are + untouched. +- Marketplace construction still creates the same source/snapshot store, fetcher, refresh + chains, provenance checks, official source state, and kill-list projection. Installer preview, + Git checkout, content inspection, immutable install, rollback, and safety checks are unchanged. +- `PluginService` still constructs its extension registry, event bus, content verifier, audit + log, log buffer, content-pack registry, panel controller, worker controller, and housekeeping + owner. Construction still forks no worker and reads no panel content; workers remain lazy + behind explicit approved triggers. +- The feature flag remains fail-closed. With it off, discovery produces no plugins, panels are + revoked, worker state reconciles empty, bundled bootstrap returns without reading resources, + and official marketplace seeding is not requested. +- Packaged plugin hosts still resolve against the `app.asar.unpacked` form with the same direct + entry/fallback behavior. Development hosts still resolve beneath the application/out tree. + Bundled plugins still resolve to `resourcesPath/plugins/launch` when packaged and + `appPath/resources/plugins/launch` in development through `path.join`. +- No macOS, Linux, Windows, SSH, WSL, remote-runtime, folder-workspace, Git-provider, or Git 2.25 + path changed. + +## Preserved wiring, readiness, and lifecycle + +All policy and lifecycle wiring remains in `src/main/index.ts`: + +- kill-list change reconciliation; +- plugin-system setting listeners, enable-time bundled bootstrap and official marketplace seed, + and packaged enable-time kill-list refresh; +- runtime RPC setter with the same consent and enablement write paths; +- fire-and-forget `PluginService.initialize()` and the + `plugin-system-initialized` duration/discovered-count milestone; +- packaged startup kill-list refresh; +- plugin change broadcasts, language-pack replacement, UI-language rebuild, and menu rebuild; +- initial bundled bootstrap and official marketplace seed; +- agent-status and worktree lifecycle event taps. + +The initial bootstrap and seed requests still occur after change listeners and initialization +have been installed. The feature setting listener in plugin IPC still refreshes on +`pluginSystemEnabled` or `devPluginPaths`, and core IPC still installs plugin, marketplace, and +ephemeral-VM handlers once with the same service objects. `setPluginServiceForRpc` still runs +before either serve or desktop `runtimeRpc.start()`. + +There was no standalone plugin null-check in `openMainWindow` before this tranche. Its readiness +contract remains the startup chain: the aggregate factory and all global assignments finish +before `services-initialized`, and both the desktop `openMainWindow()` call and serve RPC startup +occur later. `registerCoreHandlers` receives the same `pluginService`, marketplace, and installer +identities, so renderer IPC, ephemeral-VM recipes, runtime delegate binding, panel ownership, and +main-window recreation behavior are unchanged. + +Committed `will-quit` still clears runtime RPC plugin access, kill list, marketplace, and +installer globals before starting `pluginService.dispose()`, then clears the plugin global. The +same dispose path stops housekeeping, revokes panels, waits for refresh settlement, shuts down +workers, flushes the audit log, and remains in the bounded `plugin-hosts` teardown barrier. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/plugin-system-startup-capability-DlTOKOId.js` (111,343 raw / 26,060 gzip bytes). +`out/main/index.js` loads it through +`./chunks/plugin-system-startup-capability-DlTOKOId.js`. + +The entry specifier is relative, contains no parent traversal, and resolves under `out/main`. +Every relative dependency in the capability chunk was resolved, confirmed to exist, and +confirmed to remain under `out/main`: + +- `../index.js` +- `./chunk-BTjIgr6M.js` +- `./keybindings-BUptVVER.js` +- `./parcel-watcher-event-delivery-BtdYFaIv.js` +- `./plugin-host-protocol-B0PDvGKP.js` +- `./plugin-manifest-Dsyt2dd3.js` + +These paths match packaged-relative CommonJS resolution. + +## Budget + +The prior `electron-main` raw budget was 7,379,728 bytes. Lowering it by the exact measured +106,212-byte improvement produces a new budget of 7,273,516 bytes and leaves exactly 48,236 +bytes (0.668%) of headroom over the 7,225,280-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,971 main modules and the post-edit build transformed 1,972; both transformed 17 preload + modules and 9,181 renderer modules. +- Focused aggregate capability/boundary, complete plugin service integrity/reconciliation and + startup-budget suite, registries, worker/panel/event/host, kill-list, marketplace/install/ + bootstrap, plugin/core/marketplace/ephemeral-VM IPC, runtime RPC, desktop/serve startup, and + shutdown suite: passed, 55 files with 366 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on the touched source and tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on the touched source, tests, and budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,225,280 actual versus 7,273,516 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The builds and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-runtime-connectivity-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-runtime-connectivity-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..fe5f0047414 --- /dev/null +++ b/tools/benchmarks/results/phase1-runtime-connectivity-startup-boundary-2026-07-29.md @@ -0,0 +1,186 @@ +# Phase 1 runtime-connectivity startup boundary — 2026-07-29 + +**Scope:** Move the remaining runtime-connectivity, mobile registration, and pairing +composition leaves behind one exact-identity app-ready capability while preserving the +runtime server, relay service, remote-environment, mobile, serve, and shutdown owners. + +## Result + +`src/main/index.ts` now dynamically imports +`./startup/runtime-connectivity-startup-capability` after installing the retained +account-runtime coordination capability. It immediately installs the returned exact-identity +aggregate in a fail-closed owner before its first runtime transport, signer, mobile, or serve +consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,544,763 | 3,410,984 | -133,779 | 748,197 | 720,505 | -27,692 | + +The preload and renderer outputs remained byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer and consumer audit + +The audit covered `src/main/index.ts` plus every production TypeScript occurrence beneath +`src/main` and `src/shared`, excluding tests and specs, before editing: + +- `registerMobileHandlers` had one production caller, `index.ts`; `ipc/mobile.ts` owns its + implementation and all mobile IPC registration behavior. +- `loadAgentSessionClaimSigner` had one production caller, `index.ts`; + `agent-session-claim-identity.ts` retains key creation, hardening, corruption refusal, and + signer identity. +- `fingerprintOrchestrationPeer` had one production caller, `index.ts`; + `environment-transport.ts` retains the fingerprint implementation and transport contract. +- `callRuntimeEnvironment` was also consumed by runtime-session scanning, runtime-environment + IPC, and clipboard runtime image upload. The capability returns the same cached module + function; those consumers and the routing module are unchanged. +- `resolveEnvironment` and `getPreferredPairingOffer` were also consumed by the + runtime-environment store/routing/IPC/settings and shared-control paths. The capability + returns those same module function identities without wrapping or copying their state. +- `resolveAdvertisedPairingEndpoint` was also consumed by `OrcaRuntimeRpcServer`. The + capability returns the same function while the server retains its direct import. + +After the edit, the capability is the sole production value importer of all seven functions +as an aggregate. `index.ts` retains only the type import for +`OrchestrationEnvironmentTransport` and the eager, type-only owner. There was no pre-ready +call to any candidate; module-scope `printServeReady` is first invoked in the headless branch +after runtime RPC construction and mobile registration. + +## Identity and ordering + +`createRuntimeConnectivityStartupCapability()` returns the imported function references +directly: + +- `callRuntimeEnvironment`; +- `fingerprintOrchestrationPeer`; +- `getPreferredPairingOffer`; +- `loadAgentSessionClaimSigner`; +- `registerMobileHandlers`; +- `resolveAdvertisedPairingEndpoint`; and +- `resolveEnvironment`. + +The owner stores and returns that same object by identity and throws before installation. +The retained order is: + +1. Electron reaches `app.whenReady()`. +2. Existing browser, main-window, terminal, updater, shell, hook, Codex-session, + telemetry, crash/hang, and account-runtime capabilities are installed in their prior order. +3. The runtime-connectivity capability is imported, created, and installed. +4. Crash-store, browser certificate, Store, account, rate-limit, and other retained startup + work proceeds unchanged. +5. The orchestration environment transport captures the installed exact functions with the + original user-data path, selector, method, params, timeout, `undefined` revision, and + envelope expressions. +6. Runtime-service construction receives the claim signer created at the original point from + the same two profile-path expressions. +7. Runtime RPC is constructed and immediately assigned as before. +8. Mobile handlers register against that exact server with the original relay-status and + pending-auth closures before the unpaired-device callback, terminal services, or either + serve/desktop startup branch. +9. Headless readiness resolves the pairing endpoint only after RPC startup; desktop relay + construction and provider wiring remain after the window/RPC `Promise.all`. + +The capability adds no wrapper, alternate schema, lifecycle, policy, cache, listener, or +error handling. + +## Preserved policy and ownership + +Pre-ready profile/bootstrap, single-instance, GPU fallback, first breadcrumbs, cross-platform +path normalization, default WSL distro, auth-restart preservation, SSH/remote provider policy, +folder-workspace policy, Git/provider policy, and shutdown ownership remain eager and +unchanged. `awaitRuntimeFileWatcherUnsubscribes` and `clearRuntimeMetadataIfOwned` remain direct +imports used by the existing committed `will-quit` chain; this tranche did not move or wrap +runtime metadata cleanup. + +Mobile interface enumeration, Windows firewall behavior, relay status and pairing callbacks, +direct/relay offer selection, E2EE, shared-control routing, Tailscale hints, environment +revision checks, WSL/SSH/relay transport, and runtime identity remain in their existing +modules. No platform check, path expression, RPC/IPC schema, Git command, provider gate, +workspace identifier, or shutdown deadline changed. + +## Fresh A/B evidence + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-runtime-connectivity-a.B92TPl` +- B artifact: `/tmp/orca-runtime-connectivity-b.uu3FlK` +- A transformed 1,999 main, 17 preload, and 9,181 renderer modules. +- B transformed 2,001 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry hashes: + +- A SHA-256: + `19b988fd4522bc961d6e36dc5e2a807b8a6c086b8c9aae5ced4a5d722c13c018` +- B SHA-256: + `edc16e8482159539251fe9e26cc62bbf3391f3e244035cbf62c1860b093104d5` + +The sorted A and B non-main manifests each contain exactly 786 rows and are byte-identical. +Their SHA-256 is +`f72f00efd1bc891383c4664090bfcea870746c0e8de2e84a8dfa1c2f09b19da2`. + +The A main manifest contains 137 rows with SHA-256 +`9ce920dd828918677a5eb377ea5112f8e01cbd0959905af58d6ade2b250d7768`. +The B main manifest contains 144 rows with SHA-256 +`d3397a4b92ea2f1e411fd1e26c715bddc075510296b6610d49bec0d776c8d611`. + +## Emitted chunk and inclusive closure + +The retained build emits +`out/main/chunks/runtime-connectivity-startup-capability-CmTkK8-P.js` at 21,120 raw / +5,960 gzip bytes with SHA-256 +`10273cd0d99f3f05a9c06d099c4979fb7763bd9658b70121da1ecb8880d3ac50`. +Its eight direct literal relative edges are: + +- `./chunk-BTjIgr6M.js` +- `./node-bounded-file-reader-CljE4FRs.js` +- `./agent-session-resume-C5EO_KoU.js` +- `./agent-session-host-authority-8e3ngWcN.js` +- `./ws-outbound-backpressure-queue-0So6s2Fw.js` +- `./runtime-environment-transport-routing-DXhwBdYf.js` +- `./agent-session-claim-identity-D8BsfeXX.js` +- `./pairing-endpoint-FBorgZbp.js` + +An inclusive Acorn AST walk followed literal relative import, export, dynamic-import, and +`require` edges. The capability closure visited 134 JavaScript files and validated 731 +edges. A separate scan of all 144 emitted-main JavaScript files validated 781 edges. Every +target exists and resolves beneath `out/main`; no edge escapes the emitted directory. + +## Budget + +The prior Electron-main raw budget was 3,592,999 bytes. Lowering only that value by the +exact measured 133,779-byte improvement produces 3,459,220 bytes: + +`3,459,220 - 3,410,984 = 48,236` + +Preload and renderer budgets are unchanged. + +## Validation + +- Fresh A and B `pnpm run build:electron-vite`: passed. +- Focused capability, owner, source-boundary, runtime-service identity, mobile IPC, + environment request/routing/revision, claim signer, pairing endpoint, mobile pairing/RPC, + relay/E2EE, runtime RPC/service boundary, desktop ordering, and serve activation suite: + 26 files, 207 tests passed and 2 skipped. +- `pnpm run typecheck:node`: passed. +- Targeted `pnpm exec oxlint --deny-warnings`: passed. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,410,984 actual versus 3,459,220 + budgeted main bytes. +- `git diff --check`: passed. + +## Residual packaged-ASAR limitation + +The fresh production build and inclusive emitted-closure scans validate relative resolution +on this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, +or Windows; cross-platform packaged launch verification remains unresolved. diff --git a/tools/benchmarks/results/phase1-runtime-rpc-server-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-runtime-rpc-server-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..f18318de527 --- /dev/null +++ b/tools/benchmarks/results/phase1-runtime-rpc-server-startup-boundary-2026-07-29.md @@ -0,0 +1,177 @@ +# Phase 1 OrcaRuntimeRpcServer startup boundary — 2026-07-29 + +**Scope:** Move the concrete `OrcaRuntimeRpcServer` graph behind one startup capability while +preserving constructor timing, option expressions and identities, immediate global assignment, +mobile/relay consumers, desktop and headless readiness, and committed teardown. + +## Result + +`src/main/index.ts` now type-imports `OrcaRuntimeRpcServer`. At the original constructor point, +after serve-option resolution and canonical pairing-data migration, it awaits exactly one +`./startup/runtime-rpc-server-startup-capability` import and synchronously invokes the factory +with the original options object. The returned direct instance is immediately assigned to the +existing module-level `runtimeRpc` slot before every downstream consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,924,069 | 4,836,604 | -87,465 | 1,049,526 | 1,028,806 | -20,720 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison matched all 786 emitted files outside `out/main`, covering the +complete preload and renderer output. + +## Importer and constructor audit + +Before the edit, production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole constructor site. `src/main/ipc/mobile.ts` and +`src/main/runtime/relay/desktop-relay-service.ts` already imported the server type-only. +`src/main/server/serve-readiness.ts` also type-imported only +`PairingOfferUnavailableReason`. Runtime RPC unit and integration tests retain their deliberate +direct constructors. + +After the edit, `src/main/startup/runtime-rpc-server-startup-capability.ts` is the sole production +value importer and constructor site. `index.ts` has no eager value import or direct constructor, +and contains exactly one dynamic capability import and one factory call. + +## Capability contract and constructor effects + +The capability is intentionally a one-argument constructor-tuple seam: + +```ts +type OrcaRuntimeRpcServerParameters = ConstructorParameters + +export function createOrcaRuntimeRpcServerStartupCapability( + options: OrcaRuntimeRpcServerParameters[0] +): OrcaRuntimeRpcServer +``` + +It returns `new OrcaRuntimeRpcServer(options)` directly. It introduces no alternate option +schema, wrapper, policy, or lifecycle owner. + +Construction still runs synchronously at the former site and preserves these effects: + +- creates the process-scoped random RPC authentication token; +- constructs the `RpcDispatcher` with the exact runtime singleton; +- captures the canonical user-data path, PID/platform defaults, WebSocket gate and port policy, + bundled web-client root, keepalive/admission limits, and metadata ownership cadence; +- constructs `RelayRevokeOutbox` with that same canonical path, including its existing + synchronous load, secure-file hardening, JSON validation, and fail-empty fallback; +- initializes the existing transport, pairing, relay, long-poll, abort-controller, listener, + and binary-stream state without starting transports. + +No code in `runtime-rpc.ts` changed. Socket sweeping, Unix/named-pipe setup, WebSocket admission, +pairing registry and E2EE initialization, metadata publication/ownership reclaim, keepalive, +long-poll caps, relay binding, and stop semantics remain owned by `start()`/`stop()` exactly as +before. + +## Option identity and startup order + +The complete options expression remains in `index.ts`: + +- `runtime` is the same immediately assigned `OrcaRuntimeService` singleton; +- `userDataPath` remains `getCanonicalUserDataPath()`; +- `enableWebSocket` remains `true`; +- E2E still uses port `0` unless a validated `ORCA_E2E_RUNTIME_WS_PORT` override is present; +- non-E2E development still pins port `6769`; +- an explicit serve port still sets both `wsPort` and `preferPinnedWsPort: true`; +- `webClientRoot` remains `getBundledWebClientRoot()`. + +The retained ordering is: + +1. validate E2E WebSocket input and derive the development port; +2. resolve `ServeOptions`, preserving the existing exit-on-invalid-options path; +3. migrate pairing data from Electron's late user-data path to the canonical path; +4. await the single startup capability and construct the server with the original expression; +5. immediately assign the live server to module-level `runtimeRpc`; +6. register mobile IPC with the same relay-status and pending-auth callbacks; +7. install the unpaired-device failure callback; +8. start terminal runtime startup services and install macOS activation wiring; +9. continue into the unchanged serve or desktop startup branch. + +Headless serve still awaits the managed-WSL and local-PTY barriers, registers headless PTY, +conditionally attaches the offscreen backend, publishes the empty window graph, awaits +`runtimeRpc.start()`, settles activation, installs signal handlers, starts automations, and only +then publishes readiness. Desktop still starts the first window and the same server in +`Promise.all`, reports RPC startup failure against that window, and only afterward attempts +configured relay startup. + +The same server remains passed by identity to mobile handlers and `DesktopRelayService`. Relay +status, pending-auth, unpaired-device, pairing-provider, terminal, activation, WebSocket endpoint, +and runtime metadata consumers are unchanged. `before-quit` still clears the pairing provider; +`will-quit` still calls `stop()` before clearing metadata owned by the same runtime ID and PID. + +## Platform, SSH, and folder-workspace compatibility + +This change moves only module reachability. Platform checks, Windows named-pipe behavior, +Unix-socket sweeping, WSL startup barriers, SSH/remote runtime routing, relay transport, folder +workspace identifiers, and Git-worktree routing remain in their existing owners. No Git command, +provider selection, IPC/RPC schema, authentication rule, or filesystem path expression changed. + +## Emitted capability and packaged-relative closure + +The emitted capability is +`out/main/chunks/runtime-rpc-server-startup-capability-Dh0h-y00.js` +(90,302 raw / 22,416 gzip bytes). `out/main/index.js` loads it through the relative specifier +`./chunks/runtime-rpc-server-startup-capability-Dh0h-y00.js`. + +The entry specifier contains no parent traversal and resolves beneath `out/main`. The capability +has 17 direct static relative dependencies, including the expected `../index.js` cycle created by +existing RPC dependencies on main-owned exports. CommonJS resolves that cycle through the already +executing entry module. + +An AST-based graph walk from the capability visited 77 JavaScript files and validated 335 +relative edges. Every target exists and stays beneath `out/main`. A separate complete emitted-main +scan checked all 87 JavaScript files and all 376 static relative references with the same result. + +## Budget + +The prior `electron-main` raw budget was 4,972,305 bytes. Lowering only that budget by the exact +87,465-byte reduction produces 4,884,840 bytes and leaves exactly 48,236 bytes (0.997%) of +headroom over the 4,836,604-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,974 main modules and the retained build transformed 1,975; both transformed 17 preload + modules and 9,181 renderer modules. +- Focused capability, source-boundary, and inherited runtime-service boundary suite: passed, + 3 files with 10 tests. +- Complete `src/main/runtime` suite, covering the full `runtime/rpc` tree, runtime RPC server, + mobile pairing/E2EE, relay, remote runtime, orchestration, terminal, browser, account, + automation, plugin, worktree, and teardown behavior: 188 files passed and 1 skipped; 3,003 + tests passed and 6 skipped. +- Representative mobile IPC, remote-environment routing, SSH notification routing, + desktop/serve activation and readiness, core-IPC/offscreen/relay startup boundaries, macOS + activation, headless workspace, updater serve handoff, shutdown checkpoint, and relaunch + suite: 18 files and 115 tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,836,604 actual versus 4,884,840 + budgeted main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The complete runtime test run also emitted its existing warning that one test process sets +`NODE_TLS_REJECT_UNAUTHORIZED=0`; no test failed. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-runtime-service-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-runtime-service-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..81db980cf9d --- /dev/null +++ b/tools/benchmarks/results/phase1-runtime-service-startup-boundary-2026-07-29.md @@ -0,0 +1,199 @@ +# Phase 1 OrcaRuntimeService startup boundary — 2026-07-29 + +**Scope:** Move the concrete `OrcaRuntimeService` implementation behind one startup capability +while preserving its exact construction point, constructor side effects, dependency identities, +global assignment, downstream wiring, desktop/headless readiness, and committed teardown. + +## Result + +`src/main/index.ts` now type-imports both `OrcaRuntimeService` and +`RuntimeWorktreeLifecycleEvent`. At the original constructor site it awaits exactly one +`./startup/runtime-service-startup-capability` import, then synchronously calls the returned +factory with the original constructor tuple. The same live instance is immediately assigned to +the existing module-level `runtime` slot before every provider, browser, automation, account, +plugin, mobile, RPC, desktop, headless, and teardown consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 6,549,287 | 4,924,069 | -1,625,218 | 1,375,919 | 1,049,526 | -326,393 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +A SHA-256 manifest comparison matched all 786 emitted files outside `out/main`, covering the +complete preload and renderer output. + +## Importer and constructor audit + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole production constructor site for `OrcaRuntimeService`. Every other +production consumer imports the class type-only, including: + +- runtime RPC server, dispatcher, core, terminal guards, every typed method surface, federation + control/sync/setup/observation, and worktree teardown; +- core/runtime/PTY/SSH/worktree/plugin/notification/terminal-preview/workspace-cleanup IPC; +- SSH relay sessions and remote Orca CLI; +- main-window attachment, automation headless workspace creation, and agent-browser, + emulator, and offscreen-browser startup capabilities. + +The two direct constructors under `tests/e2e/fixtures` are test clients, not production entry +consumers, and remain unchanged. + +After the edit, `src/main/startup/runtime-service-startup-capability.ts` is the sole production +value importer and sole production constructor site. `index.ts` has one dynamic import, one +factory call, no direct constructor, and no eager runtime-service value import. + +## Capability interface and identity + +The capability is intentionally a constructor-tuple seam: + +```ts +type OrcaRuntimeServiceParameters = ConstructorParameters + +export function createOrcaRuntimeServiceStartupCapability( + ...args: OrcaRuntimeServiceParameters +): OrcaRuntimeService +``` + +It does not export a new dependency bag, duplicate runtime types, wrap the returned service, or +move any composition policy. The module import is awaited at the old constructor point; the +factory itself remains synchronous and returns the direct `new OrcaRuntimeService(...args)` +instance. + +The focused factory test records the constructed object and proves Store, StatsCollector, and +the complete dependency object reach the constructor by identity. Source tests prove the exact +same callback expressions remain in `index.ts`: + +- profile-scoped agent-session claim signer; +- lazy current local PTY provider and per-connection SSH provider thunks; +- provider PTY state cleanup; +- terminal agent-status ingest and live terminal side-effect renderer send; +- desktop-window status resolver; +- filtered live-agent snapshot, complete provider-session snapshot, and per-pane rows; +- additional managed-Codex AI Vault paths and legacy shared-session resume preparation; +- managed agent-hook environment builder; +- orchestration environment resolve/call transport. + +## Constructor side effects + +No `OrcaRuntimeService` implementation changed. The constructor still runs synchronously at the +same services-initialization point, preserving: + +- per-process `runtimeId` and `startedAt` creation; +- initialization of the runtime-owned orchestration federation, graph, tab, mobile navigation, + terminal identity/idempotency, PTY stream, subscription, browser, remote-desktop, worktree, + Git fetch, watcher, reconciliation, waiter, listener, and teardown maps/sets; +- mobile selection hydration from the original Store and installation of the persistence + listener back to that same Store; +- the original StatsCollector reference and `AgentDetector(stats)` identity; +- agent and provider-session status callback capture; +- shared AI Vault session-source configuration for desktop and serve; +- lazy local/SSH provider resolution, stopped-PTY cleanup, terminal status, terminal side + effects, hook environment, desktop status, AI Vault resume, agent-session signer, and + orchestration transport capture; +- registration of the ConPTY DA1 override installer and terminal view-attributes applier against + this exact live runtime; +- construction of the existing mobile coalescer, terminal-create idempotency owner, and Claude + agent-teams owner without starting unrelated external work. + +Default fallbacks remain untouched: absent orchestration transport and callbacks remain null, +desktop status still defaults to `openable`, and an absent signer still creates the ephemeral +signer from the new runtime ID. + +## Startup order and downstream identity + +The retained startup sequence is: + +1. finish account-service construction and all rate-limit/account resolver configuration; +2. create the orchestration environment transport in `index.ts`; +3. await the single runtime startup-capability module; +4. synchronously construct `OrcaRuntimeService` with the original Store, StatsCollector, signer, + callbacks, and transport; +5. immediately assign `runtime = runtimeService`; +6. publish current provider-session identities; +7. install the browser guest-state listener; +8. construct AutomationService and attach that same automation singleton; +9. attach the same account/rate-limit services and commit-message environment resolvers; +10. construct and wire the plugin system, worktree lifecycle listener, agent-browser bridge, and + emulator bridge; +11. construct runtime RPC with the same runtime instance and register mobile handlers; +12. continue unchanged into headless PTY/offscreen/RPC/readiness or desktop core-IPC/window/RPC + startup. + +The automation dispatcher still closes over the same `runtimeService` for new-per-run worktree +creation, existing-workspace terminal launch, worktree lookup, terminal wait, and terminal read. +Core IPC and window attachment still receive the module-level `runtime` assigned from that exact +instance. Runtime RPC, SSH/relay, folder workspaces, browser/emulator/offscreen capabilities, +plugins, account methods, orchestration environment calls, local/WSL/SSH/remote routing, and +Git-provider/Git-2.25 behavior are otherwise unchanged. + +Committed `will-quit` still uses the same runtime for agent-browser destruction, offscreen +browser destruction, emulator destruction, runtime ID ownership, RPC stop, watcher settlement, +and the existing bounded teardown barrier. No runtime API, RPC/IPC channel, lifecycle listener, +or shutdown policy moved. + +## Emitted capability and packaged-relative closure + +The emitted capability is +`out/main/chunks/runtime-service-startup-capability-B_2CmlDx.js` +(1,153,694 raw / 221,753 gzip bytes). `out/main/index.js` loads it through +`./chunks/runtime-service-startup-capability-B_2CmlDx.js`. + +The entry specifier is relative, contains no parent traversal, and resolves beneath `out/main`. +The capability has 30 direct static relative dependencies, including the expected `../index.js` +cycle created by existing runtime dependencies on main-owned exports. At load time the entry is +already executing and CommonJS resolves that cycle through the same module instance. + +A graph walk from the capability visited 76 JavaScript files and validated 317 relative edges. +Every target exists and stays beneath `out/main`. A separate complete emitted-main scan checked +all 86 JavaScript files and all 358 static relative references with the same result. + +## Budget + +The prior `electron-main` raw budget was 6,597,523 bytes. Lowering only that budget by the exact +1,625,218-byte reduction produces 4,972,305 bytes and leaves exactly 48,236 bytes (0.980%) of +headroom over the 4,924,069-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,973 main modules and the retained build transformed 1,974; both transformed 17 preload + modules and 9,181 renderer modules. +- Focused capability and source-boundary suite: passed, 2 files with 5 tests. +- Complete `src/main/runtime` suite, covering OrcaRuntimeService, runtime RPC/core/methods, + terminal/mobile/session/browser/account/automation/plugin/orchestration behavior, transports, + worktree lifecycle, and shutdown owners: 188 files passed and 1 skipped; 3,003 tests passed and + 6 skipped. +- Cross-domain core IPC, PTY/mobile/SSH/worktree, provider, account/automation/plugin, + browser/emulator, AI Vault, window creation/attachment/activation, desktop/serve readiness, + auth preservation, relaunch, and renderer shutdown suite: passed, 51 files with 950 tests. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,924,069 actual versus 4,972,305 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new build +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The complete runtime test run also emitted its existing warning that one test process sets +`NODE_TLS_REJECT_UNAUTHORIZED=0`; no test failed. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run a packaged ASAR launch smoke on macOS, Linux, or +Windows. Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-sftp-upload-lazy-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-sftp-upload-lazy-boundary-2026-07-29.md new file mode 100644 index 00000000000..0ada63dead2 --- /dev/null +++ b/tools/benchmarks/results/phase1-sftp-upload-lazy-boundary-2026-07-29.md @@ -0,0 +1,95 @@ +# Phase 1 SFTP upload lazy boundary — 2026-07-29 + +## Result + +Retained. Authoritative orchestration task `task_703fd93c9edd` reports that the +production Electron main entry decreased by 4,984 raw bytes and 959 gzip bytes, +the two task-scoped static-plus-dynamic false-boundary warnings disappeared, and +the implementation emitted as a separate packaged-relative chunk. + +## Importer audit and retained boundary + +Before the split, eager imports in the filesystem provider, file-upload session, +relay deploy helpers, and relay install transfers kept `sftp-upload.ts` in the +main entry despite dynamic imports from `ssh-connection.ts`. + +The retained boundary is the typed `loadSftpUploadCapability()` in +`src/main/ssh/sftp-upload-capability.ts`. Current production importers preserve +their prior owners and load the exact module only at an upload boundary: + +- `ssh-filesystem-file-upload.ts` loads `uploadFile` when an SFTP upload session + opens, after confirming that an SFTP factory exists. +- `ssh-filesystem-provider.ts` loads `uploadBuffer` only for binary SFTP writes; + raw runtime transfers stay outside the capability. +- `ssh-relay-deploy-helpers.ts` keeps its public upload and directory-creation + wrappers while loading the exact implementation on first use. +- `ssh-relay-install-transfers.ts` keeps system SSH transfers eager and loads + the SFTP implementation only for fallback transfers after namespace mapping. +- `ssh-connection.ts` loads the capability in its non-system-SSH directory, + upload-session, string-write, and buffer-write paths. + +The capability exposes the original `mkdirSftp`, `uploadBuffer`, +`uploadDirectory`, `uploadFile`, and `writeStringViaSftp` identities. It does not +move relay lifecycle, connection-manager, namespace, abort, or session +ownership. + +## Behavior preserved + +Focused boundary and transfer coverage establishes: + +- append and exclusive write flags reach `uploadBuffer` and `uploadFile` + unchanged; +- one opened SFTP session still covers a file-import session and closes through + its prior owner; +- system SSH directory and file transfers do not load the SFTP upload module; +- fallback relay transfers resolve the SFTP namespace before upload; +- aborting an in-flight fallback upload closes the SFTP session and preserves + the prior abort error; +- local, WSL, SSH, relay, runtime, and folder-workspace routing remain at their + existing owners. + +The implementation functions retain their existing stream cleanup, recursive +directory upload and cleanup, string write, and directory creation semantics. + +## Production A/B + +| Surface | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | +| ------------- | --------: | --------: | ---------: | --------: | --------: | ----------: | +| Electron main | 8,144,565 | 8,139,581 | -4,984 | 1,701,684 | 1,700,725 | -959 | + +The B build emitted +`out/main/chunks/sftp-upload-CWnSPG0Z.js`. The A graph mixed static and dynamic +edges to `sftp-upload.ts` and `ssh-relay-deploy-helpers.ts`; neither +false-boundary warning remained in B. + +The historical Electron-main raw budget moved from 8,195,000 to 8,190,016, +exactly matching the 4,984-byte reduction and leaving 50,435 bytes of headroom +over B. + +Later retained tranches changed content hashes without collapsing the boundary. +The current production output contains +`out/main/chunks/sftp-upload-Pl2pg0V1.js`, and its sibling importer +`out/main/chunks/ssh-BJXSynrt.js` contains the literal relative edge +`require("./sftp-upload-Pl2pg0V1.js")`. The current chunk is 6,562 raw bytes +with SHA-256 +`534f71c3676fd3cbbb6de33e9acb752fec6f6e67a648cd36ebbccdf532819667`. + +## Validation + +The authoritative task records these passing gates: + +- focused filesystem-provider upload-boundary and relay-transfer tests; +- fresh A and B `pnpm run build:electron-vite`; +- `pnpm run check:electron-bundle-budgets`; +- `pnpm run typecheck:node`; +- targeted `pnpm exec oxlint --deny-warnings`; +- targeted `pnpm exec oxfmt --check`; +- `pnpm run check:max-lines-ratchet`; +- `git diff --check`. + +## Limitations + +The production build and emitted relative edge establish the packaged path +shape, but no live packaged SFTP upload smoke ran on macOS, Linux, or Windows. +Cross-platform packaged upload, abort, permission, and remote-filesystem +behavior therefore remain unresolved. diff --git a/tools/benchmarks/results/phase1-shutdown-watcher-runtime-metadata-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-shutdown-watcher-runtime-metadata-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..4ec967443f8 --- /dev/null +++ b/tools/benchmarks/results/phase1-shutdown-watcher-runtime-metadata-startup-boundary-2026-07-29.md @@ -0,0 +1,182 @@ +# Phase 1 shutdown watcher/runtime-metadata startup boundary — 2026-07-29 + +**Scope:** Move the five remaining watcher shutdown, runtime-file drain, +runtime-metadata ownership, and quit-deadline values behind one exact-identity app-ready +capability while preserving their existing resource and lifecycle owners. + +## Result + +`src/main/index.ts` now dynamically imports +`./startup/shutdown-watcher-runtime-metadata-startup-capability` immediately after installing +the retained runtime-connectivity capability. It installs the exact aggregate before any +filesystem IPC handler, worktree-base watcher, runtime file watcher, runtime RPC transport, or +runtime metadata writer can be reached. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,410,984 | 776,873 | -2,634,111 | 720,505 | 174,092 | -546,413 | + +The preload and renderer outputs remained byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer and lifecycle audit + +The pre-edit audit covered every production occurrence beneath `src/main` and `src/shared`: + +- `closeAllWatchers`, `disposeWorktreeBaseDirectoryWatchers`, + `awaitRuntimeFileWatcherUnsubscribes`, `clearRuntimeMetadataIfOwned`, and + `settleTeardownWithinDeadline` each had exactly one production caller in + `src/main/index.ts` outside its defining module. +- `filesystem-watcher.ts` retains all local, WSL, SSH, Parcel native/forked watcher maps, + installation generations, capacity retries, dormant/rearm state, pending unsubscribe + tracking, timers, provider registration, error reporting, and cleanup implementation. +- `worktree-base-directory-watcher.ts` retains local polling, SSH subscriptions, generation, + pending synchronization, notification timers, head identity refresh, and warning cleanup. +- `orca-runtime-files.ts` retains runtime file watcher leases, SSH rearm, physical stop + tracking, and the pending unsubscribe set. +- `runtime-metadata.ts` retains the canonical metadata path, read/write/remove operations, and + the PID plus runtime-ID ownership comparison. +- `quit-teardown-deadline.ts` retains its 20-second default, unref'ed timer, all-settled race, + and pending-name result. + +Filesystem watcher handlers are registered only through the deferred core IPC registry after +desktop startup reaches `openMainWindow()`. Worktree-base synchronization is attached from +main-window services and later settings/repository events. Runtime file watches require the +runtime service and RPC methods. Runtime metadata is written only when the runtime RPC server +starts. All of those reachable creation paths occur after the new capability installation in +both desktop and headless serve startup. + +No local/WSL/SSH/remote path policy, folder-workspace routing, Git/provider behavior, mobile or +relay wiring, i18n/telemetry policy, platform check, watcher key, metadata path, shutdown +deadline, or packaged relative import changed. + +## Exact identity and early-quit proof + +`createShutdownWatcherRuntimeMetadataStartupCapability()` returns the five original imported +function references directly. The owner stores and returns the installed object by identity, +throws on required access before installation, and exposes one typed optional getter for the +eager `will-quit` handler. + +Pre-install quit is resource-free: + +1. Electron registers `before-quit` and `will-quit` eagerly, so quit can be requested while the + app-ready dynamic imports are still pending. +2. Before capability installation, no core filesystem IPC handler or main-window service has + registered, no runtime RPC instance exists, and no candidate-owned watcher or metadata + resource can have been created. +3. `shutdownWatchersOnce()` therefore returns a resolved promise when the optional owner is + absent. It does not set `watcherShutdownPromise` or `watcherShutdownDone`; an installation + that becomes visible later cannot be hidden by a memoized pre-install no-op. +4. The optional deadline path resolves to the same empty pending-name result (`[]`) because + every candidate teardown is resource-free. The `runtimeRpc` branch is absent, so runtime + watcher drain and owned metadata clear remain no-ops. +5. After installation, the optional getter returns the exact aggregate. Watcher cleanup enters + the original promise memoization, and any existing `runtimeRpc` uses the required fail-closed + getter for drain and metadata clear, so installed cleanup is not silently skipped. + +The retained two-pass shutdown order remains: + +1. `before-quit` fences relay/mobile work and disposes awake/rate-limit state. +2. `will-quit` stops plugin, hook, stats, browser, emulator, and PTY resources in the original + order. +3. `shutdownWatchersOnce()` starts and remains memoized for installed resources. +4. The first pass prevents default and captures `process.pid` plus + `runtime?.getRuntimeId()` synchronously. +5. RPC stop completes, runtime file watcher unsubscribes drain, then owned metadata is cleared + using `getCanonicalUserDataPath()`, the captured PID, and the captured runtime ID. +6. The unchanged named deadline members cover daemon, runtime RPC, watchers, emulator, and + plugin hosts. +7. Telemetry and observability shut down before setting the two-pass guard and calling + `app.quit()` again. + +Update-quit selection, daemon disconnect versus dev-parent shutdown, watcher rejection +logging, deadline warning behavior, and the second-pass guard are unchanged. + +## Fresh A/B evidence + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-shutdown-watcher-a.D2U64x` +- B artifact: `/tmp/orca-shutdown-watcher-b.WhEXdD` +- A transformed 2,001 main, 17 preload, and 9,181 renderer modules. +- B transformed 2,003 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry SHA-256: + +- A: + `edc16e8482159539251fe9e26cc62bbf3391f3e244035cbf62c1860b093104d5` +- B: + `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd` + +The sorted A and B non-main manifests use paths relative to `out`, contain exactly 786 rows, +and are byte-identical. Their SHA-256 is +`68ba944c45d80c14c538180894eade74a3897e120fa0bf6a89fbb60500a3ab01`. + +The A relative-path main manifest contains 144 rows with SHA-256 +`67b2600ac0301057f0353e4208dc591309c5d9bd63c9d821cc768e1f3b9ca3d9`. +The B main manifest contains 184 rows with SHA-256 +`3576805e0f10c1c6c3ca473257901f824326f4e2b1a0a224bbb50e72eb28a5f2`. + +## Emitted chunk and inclusive closure + +The retained build emits +`out/main/chunks/shutdown-watcher-runtime-metadata-startup-capability-DkU2r7j5.js` at 3,391 +raw / 1,376 gzip bytes with SHA-256 +`94aed94476aadb13ca725a18c2dffff86aebaf9c5a7f6111229fcc5ac4e32877`. +It has 44 direct literal-relative edges, including the exact emitted filesystem watcher, +worktree-base watcher, runtime-files, runtime-metadata, Parcel watcher-process, local/SSH PTY, +WSL, worktree, hook, Git-dispatch, and shared policy chunks. + +An inclusive Acorn AST walk followed literal relative import, export, dynamic-import, and +`require` edges: + +- A complete emitted main: 144 JavaScript files and 781 validated edges. +- B capability closure: 174 JavaScript files and 1,132 validated edges. +- B complete emitted main: 184 JavaScript files and 1,183 validated edges. + +Every referenced target existed and resolved beneath the copied `out/main`; no edge escaped +the emitted directory. + +## Budget + +The prior Electron-main raw budget was 3,459,220 bytes. Lowering only that value by the exact +2,634,111-byte improvement produces 825,109 bytes: + +`825,109 - 776,873 = 48,236` + +Preload and renderer budgets are unchanged. + +## Tests and validation + +- Fresh A and B `pnpm run build:electron-vite`: passed. +- Focused exact-identity capability, fail-closed/optional owner, source-boundary, early-quit, + filesystem watcher local/remote/WSL unsubscribe/rearm, worktree-base watcher, runtime-file + watcher/SSH rearm, runtime metadata/ownership, quit-deadline, desktop ordering, runtime + RPC/service, terminal, telemetry, crash/hang, desktop-shell, and offscreen shutdown suite: + 22 files and 238 tests passed. +- `pnpm run typecheck:node`: passed. +- Targeted `pnpm exec oxlint --deny-warnings`: passed. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 776,873 actual versus 825,109 budgeted + main bytes. +- `git diff --check`: passed. + +No existing boundary assertion required maintenance; only the newly added boundary coverage +refers to the five qualified capability identifiers. + +## Residual packaged-ASAR limitation + +The fresh production builds and inclusive emitted-closure scans validate relative resolution +on this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, +or Windows; cross-platform packaged launch verification remains unresolved. diff --git a/tools/benchmarks/results/phase1-skill-freshness-update-dialog-lazy-boundary-2026-07-30.md b/tools/benchmarks/results/phase1-skill-freshness-update-dialog-lazy-boundary-2026-07-30.md new file mode 100644 index 00000000000..2ee5751c59d --- /dev/null +++ b/tools/benchmarks/results/phase1-skill-freshness-update-dialog-lazy-boundary-2026-07-30.md @@ -0,0 +1,205 @@ +# Phase 1 skill-freshness update-dialog lazy boundary + +Date: 2026-07-30 + +Outcome: retained. A fresh production A/B comparison reduced the main-window static +renderer closure by 31,209 raw bytes without increasing the popout or web static +closure, changing any renderer JS/CSS count, or changing any Electron main/preload +file. + +## Scope and ownership audit + +The production path before this tranche was: + +`index.html -> assets/App-DqvhbNhX.js -> SkillFreshnessUpdateDialog` + +`App.tsx` statically imported the full dialog. The dialog in turn owned freshness, +run-store, request-store, installed-skill notification, row, dialog primitive, and +clipboard/rendering dependencies. The dialog implementation and its `"Update +skills"` title were therefore emitted in the 1,583,371-byte static +`assets/App-DqvhbNhX.js` chunk. + +The production consumers audited before editing were: + +- `App.tsx`: mounted the dialog under `LinkRoutingPreferenceDialogProvider`. +- `SkillFreshnessNudge.tsx`: remains always mounted outside that provider and remains + the startup `useSkillFreshness` scan/listener owner. +- `skill-freshness-update-dialog.ts`: already held a module-global durable + `pendingOpen` snapshot, so an open request before host subscription is retained. +- `SkillFreshnessStatusPill.tsx` and `SkillUpdateStatusSegment.tsx`: request the same + dialog; the status segment also reopens the same external run. +- `skill-update-run-store.ts`: remains the single module-cache run owner, installs + one IPC listener through `ensureSubscribed`, preserves main-owned run continuity, + success linger, cancellation, and acknowledgement. +- `Settings.tsx`: consumes freshness state for settings and was already a dynamic + entry; it remains a consumer and now re-exports the lazy dialog surface. +- `SkillsPage.tsx`: consumes freshness presentation data but did not require a + source change. +- `SkillFreshnessUpdateDialog.tsx`, `SkillUpdateRow.tsx`, + `skill-freshness-grouping.ts`, scan-issue/summary surfaces, and + `useInstalledAgentSkills`: preserve inventory rows, run state, clipboard, + installed-skill refresh, and visual behavior. + +The retained path is: + +`index.html -> assets/App-Ci_esR-V.js -> SkillFreshnessUpdateDialogHost` + +The host subscribes eagerly to the durable open snapshot, initializes its permanent +`mounted` latch from that snapshot, and eagerly calls `useSkillUpdateRun` so an +in-flight run is recovered before the dialog is requested. On the first true +snapshot it calls the shared `loadSettingsModule` dynamic importer through +`lazyWithRetry` with reload key `skill-freshness-update-dialog`; the resulting +`assets/Settings-cLZtWH0G.js` contains the dialog implementation and remains mounted +after consume/close. `Suspense` uses the existing null fallback, so no layout, +tokens, or intermediate UI changed. + +The host passes the exact eager function/icon identities into the lazy surface. +This keeps the freshness cache, run store, request snapshot, installed-skill event, +and Lucide module instances singletons rather than creating parallel lazy copies. +The settings module loader is shared with the existing Settings lazy surface, so +the retained build adds no dynamic entry or static JS/CSS file. + +The first allocation attempts were rejected because Rollup named the remaining +shared freshness chunk `useSkillFreshness-*`, increasing the web preload table by +two raw bytes. The retained source puts the implementation in the concrete +`hooks/skill-freshness.ts` module, retains `hooks/useSkillFreshness.ts` as a typed +compatibility facade, and qualifies eager consumers to the concrete module. This +restores the `skill-freshness-*` emitted name and exact A web raw size. The focused +source assertion was intentionally maintained to check the shared Settings loader +instead of the superseded direct `import('../settings/Settings')` expression. + +No terminal/runtime routing, provider, SSH, relay, folder-workspace, keybinding, +platform, or UI behavior was changed. + +## Fresh production A/B + +Command for both builds: + +```text +pnpm run build:electron-vite +``` + +Archived artifacts: + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A: `/tmp/orca-skill-freshness-dialog-a.nkg3c6` +- B: `/tmp/orca-skill-freshness-dialog-b10.ixO491` + +| Entry | A raw | A gzip | A JS/CSS | B raw | B gzip | B JS/CSS | Raw delta | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Electron main entry | 776,873 | 174,092 | 1/0 | 776,873 | 174,092 | 1/0 | 0 | +| Electron preload entry | 130,798 | 20,642 | 1/0 | 130,798 | 20,642 | 1/0 | 0 | +| Main renderer static closure | 8,450,230 | 1,883,919 | 292/2 | 8,419,021 | 1,878,382 | 292/2 | -31,209 | +| Popout renderer static closure | 4,507,253 | 984,615 | 77/2 | 4,507,253 | 984,615 | 77/2 | 0 | +| Web renderer static closure | 4,360,652 | 928,347 | 33/1 | 4,360,652 | 928,352 | 33/1 | 0 | + +The renderer closure gzip figures are sums of each independently gzipped emitted +file, matching the checked-in budget reporter. Web gzip changed by five bytes due +to changed hashed dependency names, but web raw and file counts are exactly +unchanged as required. + +Entry and closure SHA-256 evidence: + +| Entry | A emitted file / SHA-256 | B emitted file / SHA-256 | A closure-manifest SHA-256 | B closure-manifest SHA-256 | +| --- | --- | --- | --- | --- | +| Main renderer | `assets/index-DrMHawfZ.js` / `8944937a20e1c0d676e0705c061c12f233afdcfb2aadabbf6cdf5b88c37630c6` | `assets/index-BA5SMP-t.js` / `de8e663ddf2221774a5ca15e060077fd7fcc5a62bcc81c962608eb15faa0d964` | `1463a4a360c28cacbd04aaceaddb8a4f64ceebf13097cd835084ea58761b1cdf` | `319b1e52def92f4b5cffb46e3ec1b51ab741835f519360e0c6b8bb401ab9592d` | +| Popout | `assets/popout-CySA9seP.js` / `87f85c7905deb74461d6f8f72fb121dade63c4d033454ab174bc6ca1b9a70d40` | `assets/popout-BD9-2Cja.js` / `1ae8a4ad2c961bb5fb3be2f515b0335576526b0cd1b409ebc66f1d9e60ded74a` | `b9fc458caae4fe8cbadfaf44e2ed5e2efc88489cc4b5a9af8398f7be6c9b3a59` | `e20bb9bfab534fcbe64f1d82ebe3d27c5a39c3158e803511ee6967a042371c06` | +| Web | `assets/web-5bKeFUJG.js` / `5269f9e2bb4bab659469042da62c725ca6712edd69ea47d31dd1aee1e54e6ff0` | `assets/web-8LsiV0SA.js` / `87e5081c86f08716e735031827995748574abe24d2ae09a0bd2008d2c1021de9` | `4a9ca9658821d0657b2adba180f4917f17f14e47d37aac9389b12bbb787ee0c7` | `8846b5c61f38b60ee80db3482ba018d12e37ae3f994de1f3ccb7cdeef8fa7949` | + +Electron identities: + +- Main `out/main/index.js` A/B SHA-256: + `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd`. +- Preload `out/preload/index.js` A/B SHA-256: + `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f`. +- All 184 files under `out/main` were byte-for-byte identical. The sorted + path/size/SHA manifest hash was + `907325898b9e2dcdbd572ca60c10d4595f656933b070566c9cea5050c7157cb8` + for both builds. +- The one preload file was byte-for-byte identical. Its sorted manifest hash was + `6533a865267aa457293cf183850e1480898704395f4431682703824557c84d97` + for both builds. +- A renderer manifest: 403,061 bytes, + `4142e98fc5f05ffd55e1c08e8cc4d0a4afb2997f95edafeb99c39dac71a2839b`. +- B renderer manifest: 403,000 bytes, + `dc7342d1664bca2c5f42074479a81e7670655adde174c65718571c5311667123`. +- Both renderer trees contained 786 emitted files plus the manifest. + +## Manifest and inclusive Acorn validation + +Both renderer manifests had 778 records and all 778 referenced emitted targets +existed beneath `out/renderer`. + +| Validation | A | B | +| --- | ---: | ---: | +| Manifest import/dynamic-import edges | 6,462 | 6,460 | +| JavaScript targets parsed with Acorn (`sourceType: module`, latest ECMAScript) | 697 | 697 | +| AST module edges | 6,493 | 6,491 | +| Literal relative emitted edges | 6,491 | 6,489 | +| Missing/out-of-root targets | 0 | 0 | +| Parse failures | 0 | 0 | + +The walk included manifest `imports`, `dynamicImports`, CSS/assets, static imports, +re-exports, and literal dynamic imports. The B static main closure does not contain +the dialog implementation; the implementation is reachable through the existing +Settings dynamic entry. + +## Budgets + +Only `renderer-index.maxRawBytes` was reduced, from 8,500,037 to 8,468,828, +exactly matching the retained 31,209-byte reduction. B therefore retains the prior +49,807-byte main-renderer raw headroom. File-count budgets were unchanged. + +Electron main remains at 776,873 raw against 825,109, exactly 48,236 bytes of +headroom. + +## Behavior and quality gates + +- Focused Vitest: 6 files, 54 tests passed. + - durable request before host subscription; + - first-load null Suspense fallback and one lazy load; + - close/reopen preserves mount identity and component-local state; + - active-run close/reopen preserves pending rows and one `getUpdateRun` + subscription; + - nudge-owned shared freshness scan/listener behavior; + - status-bar reopen; + - freshness hook cache/invalidation behavior; + - reload key `skill-freshness-update-dialog`. +- `pnpm run typecheck:web`: passed. +- Targeted `oxlint --deny-warnings`: passed. +- Targeted `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered + suppressions and no new bypass. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +No max-lines disable or budget increase was added. + +## Files in this tranche + +- `config/electron-bundle-budgets.json` +- `src/renderer/src/App.tsx` +- `src/renderer/src/components/settings/Settings.tsx` +- `src/renderer/src/components/settings/settings-module-loader.ts` +- `src/renderer/src/components/skills/SkillFreshnessUpdateDialogHost.tsx` +- `src/renderer/src/components/skills/skill-freshness-update-dialog-lazy-boundary.test.tsx` +- `src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx` +- `src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx` +- `src/renderer/src/components/skills/SkillUpdateRow.tsx` +- `src/renderer/src/components/skills/skill-freshness-update-dialog.ts` +- `src/renderer/src/components/skills/SkillFreshnessNudge.tsx` +- `src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx` +- `src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx` +- `src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx` +- `src/renderer/src/hooks/skill-freshness.ts` +- `src/renderer/src/hooks/useSkillFreshness.ts` + +## Residual limits + +No packaged macOS, Linux, or Windows launch was performed. Packaged ASAR loading, +platform-specific renderer startup, and a real IPC-backed update run remain +unvalidated in this tranche; the evidence is production-build, static-closure, +type/lint/format, and focused jsdom/happy-dom behavior coverage. diff --git a/tools/benchmarks/results/phase1-star-nag-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-star-nag-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..51e21cf87b1 --- /dev/null +++ b/tools/benchmarks/results/phase1-star-nag-startup-boundary-2026-07-29.md @@ -0,0 +1,94 @@ +# Phase 1 star nag startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `StarNagService` construction, `start()`, and IPC registration out of + the eager Electron main graph while preserving startup order and the committed quit lifetime. + +## Result + +`src/main/index.ts` now type-imports `StarNagService`, awaits the dynamic +`./startup/star-nag-startup-capability` import, and awaits +`createStarNagStartupCapability(store, stats)` at the original construction site. The factory +constructs one service, calls `start()`, registers its IPC handlers, and returns that same live +instance for the existing `will-quit` `stop()` call. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,959,821 | 7,946,692 | -13,129 | 1,661,668 | 1,659,337 | -2,331 | + +The other static startup graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Importer and lifecycle evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the +sole value importer of `./star-nag/service`. The only production lifecycle references were the +adjacent construction, `start()`, and `registerIpcHandlers()` calls at that site and the single +`starNag?.stop()` inside the committed `app.on('will-quit', ...)` handler. No other production +consumer constructed, started, registered, or stopped the service. + +After the edit, `src/main/startup/star-nag-startup-capability.ts` is the sole production value +importer and `src/main/index.ts` is type-only. The source boundary test rejects eager value +imports and direct construction, requires exactly one awaited capability import and factory +call, fixes the factory before agent-browser attachment, emulator attachment, and +`services-initialized`, and preserves the single committed `will-quit` stop. The factory +lifecycle test confirms constructor arguments, `start()` before IPC registration, and return of +the constructed live instance. + +The import remains at the exact prior lifecycle point after plugin startup wiring and before +agent-browser and emulator attachment. The awaited boundary therefore preserves startup +readiness and IPC availability rather than moving StarNag initialization later. `start()` still +installs the `StatsCollector` listener at that point, and the service implementation was not +changed, preserving prompt state, GitHub direct-star and web fallback behavior, telemetry, +desktop versus serve behavior, and teardown. + +## Generated chunk and packaged-relative resolution + +The emitted implementation chunk is +`out/main/chunks/star-nag-startup-capability-Bqy8weqR.js` (13,732 raw / 3,207 gzip bytes). +`out/main/index.js` loads it with +`require("./chunks/star-nag-startup-capability-Bqy8weqR.js")`. The specifier is relative to the +main entry, contains no parent traversal, and resolves to the emitted file beside the entry's +`chunks` directory, matching packaged ASAR-relative lookup. + +## Budget + +The `electron-main` raw budget is 7,994,928 bytes. This lowers the prior 8,008,057-byte budget by +the exact measured 13,129-byte improvement and leaves 48,236 bytes (0.607% over the measured +entry) of headroom. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,957 main modules; the post-edit build transformed 1,958 and emitted the deferred chunk. +- Focused StarNag and startup ordering suite: passed, 7 files and 54 tests. It included the + existing `StarNagService` tests, both new boundary tests, adjacent agent-browser and emulator + boundaries, desktop startup ordering, and serve/desktop activation wiring. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the capability, and its two tests: passed + with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,946,692 actual versus 7,994,928 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitations + +Both production builds contained the same two existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`; no new warning appeared. + +This boundary reduces the eager static entry but does not defer StarNag until a later user +action: the awaited chunk still loads during startup at the original service ordering point. +Generated output proves packaged-relative chunk resolution in this worktree, but this tranche +did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. It also did not measure +filesystem or ASAR decompression latency on first chunk load; raw and gzip bundle sizes are the +retention gate. diff --git a/tools/benchmarks/results/phase1-stats-collector-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-stats-collector-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..0408f2dac43 --- /dev/null +++ b/tools/benchmarks/results/phase1-stats-collector-startup-boundary-2026-07-29.md @@ -0,0 +1,137 @@ +# Phase 1 StatsCollector startup boundary + +- **Date:** 2026-07-29 +- **Scope:** Move only `StatsCollector` construction out of the eager Electron main graph while + preserving synchronous stats-path capture before Electron app naming and every existing + collector consumer and lifecycle. + +## Result + +`src/main/index.ts` now type-imports `StatsCollector`, eagerly imports only `initStatsPath` from +the concrete `src/main/stats/stats-file-path.ts` module, and awaits the dynamic +`./startup/stats-collector-startup-capability` import at the original construction site. The +factory calls only `new StatsCollector()` and returns that same live instance. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 7,909,325 | 7,904,344 | -4,981 | 1,652,751 | 1,651,679 | -1,072 | + +The other static startup graphs were byte-for-byte unchanged. A full SHA-256 manifest comparison +of every file under `out/preload` and `out/renderer` also matched: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Importer evidence + +Before the edit, a production-source search excluding tests found `src/main/index.ts` as the sole +value importer and sole direct constructor of `src/main/stats/collector.ts`. The remaining +production consumers were already type-only: + +- `src/main/stats/agent-detector.ts` +- `src/main/runtime/orca-runtime.ts` +- `src/main/startup/star-nag-startup-capability.ts` +- `src/main/star-nag/service.ts` +- `src/main/star-nag/prompt-context.ts` +- `src/main/star-nag/web-handoff.ts` +- `src/main/star-nag/threshold-trigger.ts` +- `src/main/star-nag/console-events.ts` +- `src/main/ipc/register-core-handlers.ts` +- `src/main/ipc/github.ts` +- `src/main/ipc/hosted-review.ts` +- `src/main/ipc/stats.ts` + +After the edit, `src/main/startup/stats-collector-startup-capability.ts` is the sole production +value importer and direct constructor. `src/main/index.ts` and all runtime, StarNag, and IPC +consumers remain type-only. + +`collector.ts` still re-exports `initStatsPath` for source API compatibility. Only the path +state, initializer, and fallback getter moved into `stats-file-path.ts`; the collector behavior +and public class API were not redesigned. + +## Preserved path timing and lifecycle + +`initStatsPath()` remains a direct synchronous call in the existing single-instance-lock block: + +1. It still runs after `configureDevUserDataPath()` and `initDataPath()`. +2. It still captures `app.getPath('userData')` before `app.whenReady()` and + `app.setName(devInstanceIdentity.appName)`. +3. It still resolves exactly `/orca-stats.json`. +4. The fallback getter still captures once on first access if startup initialization was skipped. + +The path-state test changes the mocked late Electron user-data path after initialization and +proves reads stay on the early captured path. It also proves the fallback path is cached after +its first lookup. + +The awaited capability import and factory replace `new StatsCollector()` at its exact former +services point, after both cohort classifiers and before Claude, Codex, and OpenCode usage-store +construction. The factory therefore finishes before runtime construction, StarNag startup, +window IPC registration, and `services-initialized`. + +The same returned global singleton remains wired to: + +- `OrcaRuntimeService`, which retains it for `getStatsSummary`, GitHub review recording, and the + same `AgentDetector`. +- `StarNagService` through the existing dynamically imported startup capability. +- Core handlers, which pass it unchanged to GitHub, hosted-review, and stats IPC registrations. +- The only `stats?.flush()` call, still inside the committed `will-quit` handler before + `killAllPty()` so live agents are closed out before PTY teardown. + +No stats IPC contract, runtime method, StarNag API, readiness milestone, initialization state, +consumer ordering, or quit commitment changed. + +## Generated chunk and packaged-relative resolution + +The emitted capability chunk is +`out/main/chunks/stats-collector-startup-capability-BY2-NC9e.js` (5,681 raw / 1,753 gzip bytes). +`out/main/index.js` loads it with +`require("./chunks/stats-collector-startup-capability-BY2-NC9e.js")`. + +The entry specifier is relative, contains no parent traversal, and resolves to the emitted file +under `out/main/chunks`. Both relative dependencies in the capability chunk were resolved and +confirmed to exist under `out/main`: `./chunk-BTjIgr6M.js` and the expected `../index.js` +reference to the already-loaded main entry that owns the eager stats-path getter. These paths +match packaged-ASAR-relative lookup. + +## Budget + +The `electron-main` raw budget is 7,952,580 bytes. This lowers the prior 7,957,561-byte budget by +the exact measured 4,981-byte improvement and leaves 48,236 bytes (0.610%) of headroom over the +7,904,344-byte entry. No preload or renderer budget changed. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,960 main modules; the post-edit build transformed 1,962 and emitted the path and capability + modules. +- Focused StatsCollector, StarNag, runtime, IPC, and startup suite: passed, 17 files and 208 + tests. Coverage included path capture and fallback state, async save and synchronous flush, + agent detection and post-exit leak prevention, StarNag behavior and startup capability, + runtime RPC wiring, core-handler identity wiring, GitHub and hosted-review stat recording, + desktop/serve ordering and activation, and runtime RPC startup failure. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint` on `src/main/index.ts`, the collector, path module, capability, and + focused tests: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check` on those files and the budget: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 7,904,344 actual versus 7,952,580 budgeted + main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and residual limitation + +Both production builds contained the same two existing CSS optimizer warnings and no new +warning: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The build and explicit resolution check prove packaged-relative emitted paths on this macOS +worktree, but this tranche did not run a packaged ASAR launch smoke on macOS, Linux, or Windows. +Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-store-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-store-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..24db71518c2 --- /dev/null +++ b/tools/benchmarks/results/phase1-store-startup-boundary-2026-07-29.md @@ -0,0 +1,204 @@ +# Phase 1 Store startup boundary — 2026-07-29 + +**Scope:** Remove the concrete persistence implementation from the eager Electron-main graph while +preserving one shared early path state, mobile pairing migration, Store construction effects, +active-profile identity, downstream service identity, and committed teardown. + +## Result + +`src/main/index.ts` now type-imports `Store`, eagerly imports only the narrow +`persistence-data-path` API, and awaits one `./startup/store-startup-capability` module at the +former constructor point. The factory synchronously constructs the direct Store with the original +`{ dataFile: activeOrcaProfile.dataFile }` object, and `index.ts` immediately assigns that live +instance to its existing module-level slot. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,836,604 | 4,592,251 | -244,353 | 1,028,806 | 978,843 | -49,963 | + +The preload and all renderer static graphs were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 89,303 bytes and had SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; a direct diff produced no +output. + +## Production importer and eager-graph audit + +Before the edit: + +- `src/main/index.ts` was the sole production Store constructor and value-imported `Store`, + `initDataPath`, `getCanonicalUserDataPath`, and + `migrateMobilePairingDataToCanonicalUserDataPath` from `persistence.ts`. +- `src/main/ipc/cli.ts`, `src/main/macos-tcc-prompt-notice.ts`, + `src/main/serve-update-handoff.ts`, and + `src/main/ssh/ssh-remote-cli-host-passthrough.ts` value-imported the canonical path getter from + `persistence.ts`. +- `src/main/ipc/onboarding.ts` value-imported the unrelated `sanitizeOnboardingUpdate` policy. It + remains unchanged behind the existing deferred aggregate core-IPC boundary. +- The remaining production Store consumers imported it type-only. The eager `index.ts` path + imports nevertheless made the complete persistence implementation reachable from + `out/main/index.js`. + +After the edit: + +- `src/main/startup/store-startup-capability.ts` is the sole production Store value importer and + constructor site. +- `index.ts` has one type-only Store import, one dynamic capability import, one factory call, and + no direct constructor. +- All five early path consumers use `persistence-data-path.ts`; none imports the path API from + `persistence.ts`. +- `persistence.ts` imports the concrete data-file getter from that module and compatibility + re-exports the three existing public path functions, so source consumers/tests retain their + contract without a second state owner. + +The B build emits the persistence graph as a separate chunk rather than keeping it in the +4,592,251-byte entry. + +## Shared path state and migration + +`persistence-data-path.ts` owns exactly one pair of captured values: the canonical user-data +directory and its `orca-data.json` path. The behavior is unchanged: + +- `initDataPath()` reads `app.getPath('userData')` once at its existing top-level startup point, + after development-path redirection and before `app.setName`. +- The data-file fallback reads the current Electron user-data path and initializes both values. +- The canonical-directory fallback reads the current Electron user-data path only when no + directory has been captured. +- A later explicit `initDataPath()` still replaces either fallback with the intended startup + capture. +- Every path is assembled with `path.join`; source/target equivalence uses `path.resolve`, so + Windows, macOS, and Linux separator and normalization behavior is preserved. + +Mobile credential migration still: + +1. no-ops when source and canonical directories resolve identically; +2. derives both paths from the shared registry/keypair filename source of truth; +3. requires the complete source pair; +4. refuses to overwrite when either canonical target exists; +5. creates the canonical directory recursively; +6. copies both files and re-applies `hardenExistingSecureFile` to each target, retaining POSIX + modes and Windows current-user ACL hardening. + +Focused tests prove fallback capture, post-`app.setName` stability, direct/re-export function +identity, shared state, `path.join` output, paired migration, and both hardening calls. The +pre-existing mobile pairing path suite continues to cover all-or-nothing migration, partial +target/source handling, same-directory/fresh-install no-ops, canonical registry/E2EE placement, +and restart discovery. + +## Store capability and constructor effects + +The capability uses the real constructor tuple and returns the direct object: + +```ts +type StoreParameters = ConstructorParameters + +export function createStoreStartupCapability(...args: StoreParameters): Store { + return new Store(...args) +} +``` + +No persistence option, schema, default, migration, or listener moved. At the original startup +point the same Store constructor still synchronously: + +- captures the active profile data file so later asynchronous writes cannot follow a global path; +- derives profile and legacy terminal scrollback snapshot roots; +- loads the persisted file with the existing default, corruption recovery, rolling backup, + safeStorage decrypt, and normalization behavior; +- performs pane identity, workspace/folder, project, settings, UI, SSH/WSL/remote, automation, + plugin, account, notification, and other existing migrations; +- creates the sidecar active-view preference; +- publishes migration-unsupported PTY entries and legacy pane aliases; +- registers PTY migration and agent-hook alias persistence listeners; +- schedules the same deferred save when normalization, load recovery, or folder adaptation + changed state. + +Atomic/durable persistence, encryption, backup rotation, coalesced writes, no-op hashing, +freeze/flush behavior, sidecar caches, schema/defaults, and every Store API remain untouched. + +## Startup order and singleton identity + +The retained order is: + +1. capture persistence, AI Vault cache, profile, stats, and usage paths before `app.setName`; +2. complete Electron readiness setup and resolve the active Orca profile; +3. await the single Store capability; +4. synchronously construct with `activeOrcaProfile.dataFile`; +5. immediately assign module-level `store`; +6. publish the existing `store-loaded` milestone; +7. hydrate WSL fallback settings, install settings listeners, seed live Claude PTYs, apply the app + icon/proxy/browser session, and initialize telemetry/observability; +8. pass that same Store to usage, account, keybinding, runtime, automation, plugin, IPC/RPC, + window, desktop, serve, folder-workspace, SSH/WSL/remote, and updater consumers; +9. retain the existing committed `store?.flush()` in `will-quit`. + +The added `await` occurs exactly where the direct constructor ran, after active-profile resolution +and before the first Store read. No readiness, renderer, desktop/headless, RPC, or window +consumer can observe an uninitialized Store. + +## Emitted chunks and packaged-relative closure + +The emitted factory is +`out/main/chunks/store-startup-capability-Bpu3rlAJ.js` +(596 raw / 319 gzip bytes). It statically reaches +`out/main/chunks/persistence-CKwdX_di.js` +(229,323 raw / 47,063 gzip bytes), which contains the deferred persistence implementation. +`out/main/index.js` loads the factory through the relative specifier +`./chunks/store-startup-capability-Bpu3rlAJ.js`. + +The capability has 9 direct static relative dependencies. An AST-based walk from it visited 84 +JavaScript files and validated 379 relative edges, including the expected existing CommonJS cycle +through `index.js`. A separate complete emitted-main scan checked all 94 JavaScript files and all +422 static relative references. Every target exists and resolves beneath `out/main`; no specifier +escapes through parent traversal. + +## Budget + +The prior `electron-main` raw budget was 4,884,840 bytes. Lowering only that budget by the exact +244,353-byte reduction produces 4,640,487 bytes and leaves exactly 48,236 bytes (1.050%) of +headroom over the 4,592,251-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed. The baseline transformed + 1,975 main modules and the retained build transformed 1,977; both transformed 17 preload + modules and 9,181 renderer modules. +- Focused path, migration, capability, import-boundary, startup-order, and affected-importer + suite: 9 files and 94 tests passed. +- Broad persistence/profile suite: 22 files and 531 tests passed. +- Complete `src/main/runtime` suite, including mobile pairing/E2EE, RPC, relay, account/runtime + identity, remote routing, folder/worktree lifecycle, and teardown: 188 files passed and 1 + skipped; 3,003 tests passed and 6 skipped. +- Representative account, runtime-home, core/mobile IPC, serve readiness, SSH/remote, + folder-workspace, window, shutdown, and relaunch suite: 32 files passed; 485 tests passed and 5 + skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 4,592,251 actual versus 4,640,487 + budgeted main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds contained the same two existing CSS optimizer warnings: + +1. `::highlight(markdown-preview-search-match)` is reported as an unrecognized pseudo-element. +2. `::highlight(markdown-preview-search-active-match)` is reported as an unrecognized + pseudo-element. + +The complete runtime test run also emitted its existing warning that one test process sets +`NODE_TLS_REJECT_UNAUTHORIZED=0`; no test failed. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains the residual limitation. diff --git a/tools/benchmarks/results/phase1-synthetic-title-behavior-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-synthetic-title-behavior-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..60e94bd7cc6 --- /dev/null +++ b/tools/benchmarks/results/phase1-synthetic-title-behavior-startup-boundary-2026-07-29.md @@ -0,0 +1,139 @@ +# Phase 1 synthetic-title behavior startup boundary — negative audit — 2026-07-29 + +**Scope:** Audit and measure one exact-identity app-ready aggregate for the six remaining +synthetic-title and TUI-permission values imported directly by `src/main/index.ts`. + +## Result + +The candidate was safe to install after the retained runtime-connectivity capability, but it +was not a raw Electron-main reduction. The candidate increased `out/main/index.js` by 330 +bytes, so its capability, owner, tests, and `index.ts` wiring were reverted. This report is +the only retained file from the tranche; the Electron-main budget remains unchanged. + +| Surface | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | +| ------------- | --------: | --------: | ---------: | ------: | ------: | ----------: | +| Electron main | 3,410,984 | 3,411,314 | +330 | 720,505 | 720,390 | -115 | + +The B raw result fails the mandatory reduction gate. It would also leave only 47,906 bytes +below the unchanged 3,459,220-byte budget, rather than the required 48,236. A post-revert +production build returned exactly to 3,410,984 raw / 720,505 gzip with 48,236 bytes of +headroom. + +## Production importer and consumer audit + +The audit covered every production occurrence beneath `src/main` and `src/shared` before +editing: + +- `advanceSyntheticTitleSpinnerEntries`, `shouldSendSyntheticTitleFrame`, + `shouldCopySyntheticTitleFrameToPtyData`, `shouldDriveSyntheticAgentTitleFromHook`, and + `resolveTuiAgentPermissionMode` each had one direct production caller in `src/main/index.ts` + outside their implementation modules. +- `getSyntheticAgentTitleProfile` also remains a production dependency of + `src/shared/agent-title-owner.ts` and `src/shared/foreground-wrapper-agent.ts`. +- The hook consumers are callback bodies registered by `openMainWindow()`. + `agentHookServer.setListener` can synchronously replay cached status, so installation had to + precede the first possible `openMainWindow()` call. +- Frame visibility and PTY routing are reached from `sendSyntheticTitle`; spinner advancement + is reached only from the interval created after a spinner entry exists; permission-mode + derivation is reached only from the hook callback. +- Pre-ready second-instance activation cannot reach these consumers: + `focusExistingMainWindow()` returns `pending` until `app.isReady()`, and after ready the + Store/runtime/account/service guards needed by `openMainWindow()` are initialized only after + the proposed installation point. + +The measured candidate therefore installed immediately after +`installRuntimeConnectivityStartupCapability(runtimeConnectivity)` and before crash-store +creation or any reachable window/hook consumer. Its typed owner threw before installation and +returned the exact installed aggregate object. The aggregate returned all six original +function references without wrappers. + +No map, constant, timer, listener, hook publication, PTY lookup, PTY data copy, BEL/final-frame +path, renderer visibility check, permission calculation, or teardown function moved. The +candidate did not alter pre-ready bootstrap/profile or platform policy, Store/account/runtime, +mobile/relay/i18n, macOS/Linux/Windows, WSL/SSH/remote, folder-workspace, Git/provider, plugin, +or shutdown ownership. + +## Why the candidate did not reduce raw main + +The candidate emitted +`out/main/chunks/synthetic-title-behavior-startup-capability-CWRc-CtO.js` at 1,403 raw / 563 +gzip bytes with SHA-256 +`d4874144a1274c05cb6bd3be214ad8ebb5378386f662ec79e723bfd585fee029`. + +Its three direct literal-relative edges were: + +- `./tui-agent-config-DMkBaphp.js`; +- `./synthetic-agent-title-B5wBI6ut.js`; and +- `../index.js`. + +The emitted capability had to import `resolveTuiAgentPermissionMode` back from `index.js`. +Together with the remaining production importers of the shared synthetic-agent-title module, +the exact-identity aggregate did not remove enough code from the entry to offset its owner, +getter calls, dynamic-import wiring, and new chunk. The resulting circular entry edge is valid, +but the raw entry grew, making this exact six-value aggregate unsuitable for retention. + +## Fresh A/B evidence + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A artifact: `/tmp/orca-synthetic-title-a.DRSTOq` +- B artifact: `/tmp/orca-synthetic-title-b.CQdzZU` +- A transformed 2,001 main, 17 preload, and 9,181 renderer modules. +- B transformed 2,003 main, 17 preload, and 9,181 renderer modules. +- Both builds emitted only the two existing CSS `::highlight(...)` parser warnings. + +Entry SHA-256 values: + +- A: + `edc16e8482159539251fe9e26cc62bbf3391f3e244035cbf62c1860b093104d5` +- B: + `b3c3b5092c07e6a5eba30f99e98713074945fbfc9e806ac6b3d28ddd2d3ca9b9` +- post-revert: + `edc16e8482159539251fe9e26cc62bbf3391f3e244035cbf62c1860b093104d5` + +The sorted A and B non-main manifests use paths relative to `out`, contain exactly 786 rows, +and are byte-identical. Their SHA-256 is +`68ba944c45d80c14c538180894eade74a3897e120fa0bf6a89fbb60500a3ab01`. + +The relative-path A main manifest contains 144 rows with SHA-256 +`67b2600ac0301057f0353e4208dc591309c5d9bd63c9d821cc768e1f3b9ca3d9`. +The B main manifest contains 146 rows with SHA-256 +`07d71a13d8bc6b8d189ea07b22fb984a022e39b55715a7bedf49aa2d6bcbdfa8`. + +## Inclusive emitted closure + +An Acorn AST walk followed literal relative import, export, dynamic-import, and `require` +edges: + +- A complete emitted main: 144 JavaScript files and 781 validated edges. +- B capability closure: 136 JavaScript files and 737 validated edges. +- B complete emitted main: 146 JavaScript files and 787 validated edges. + +Every referenced target existed and resolved beneath its copied `out/main`; no edge escaped +the emitted directory. + +## Tests and gates + +Before measurement, the candidate capability, owner, source-boundary, spinner, visibility, +frame-routing, shared synthetic-agent-title, and TUI-permission suite passed: 8 files and 27 +tests. The new coverage proved exact function identity, fail-closed access, installed object +identity, ordering after runtime-connectivity and before window creation, type-only/absent +direct source imports, and preservation of maps, timers, routing, and teardown ownership. + +No existing boundary assertion required maintenance: only the discarded candidate's new +source-boundary test referred to the qualified identifiers. + +Because the mandatory fresh raw-size gate failed, the candidate was reverted before the +broader hook/PTY/runtime/startup/teardown, typecheck, lint, format, ratchet, and retained-budget +gates. After the revert, the five retained behavior files passed 20 tests; targeted report +formatting, `check:max-lines-ratchet`, `check:electron-bundle-budgets`, non-main manifest +equality, and `git diff --check` also passed. The post-revert production build reproduced the +accepted baseline exactly, and the checked-in Electron-main budget was not changed. + +## Residual packaged-ASAR limitation + +The fresh production builds and inclusive emitted-closure scans validate relative resolution +on this macOS worktree. This negative audit did not run packaged-ASAR launch smokes on macOS, +Linux, or Windows; cross-platform packaged launch verification remains unresolved. diff --git a/tools/benchmarks/results/phase1-telemetry-observability-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-telemetry-observability-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..5034d0d387f --- /dev/null +++ b/tools/benchmarks/results/phase1-telemetry-observability-startup-boundary-2026-07-29.md @@ -0,0 +1,198 @@ +# Phase 1 telemetry/observability startup boundary — 2026-07-29 + +**Scope:** Move the remaining direct Electron-main telemetry and observability +composition values behind one exact-identity app-ready capability while preserving the +two lanes' independent state and lifecycle order. + +## Result + +`src/main/index.ts` now performs one dynamic import of +`./startup/telemetry-observability-startup-capability` immediately after the retained +Codex launch/session capability. It installs the exact returned object in a typed owner +before Store construction or any live telemetry/observability consumer. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,590,383 | 3,573,732 | -16,651 | 759,456 | 755,124 | -4,332 | + +The preload and renderer outputs were byte-identical: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +## Production importer audit + +Before this tranche, `index.ts` directly value-imported: + +- `initObservability` and `shutdownObservability`; +- `initTelemetry`, `shutdownTelemetry`, `track`, and `trackAppOpenedOnce`; +- `classifyError`; +- `initCohortClassifier`; +- `initOnboardingCohortClassifier`; and +- `resolveConsent`. + +The complete production audit found these other importer groups: + +- `runtime/runtime-rpc-startup-failure.ts` remains an intentional eager leaf and + imports the same telemetry `track` function for transport-start failure reporting. + This keeps the client reachable in the entry, but does not create a second client, + Store reference, session, burst cap, consent state, or shutdown gate. +- Retained agent-hook, terminal-runtime, account, runtime-service, core-IPC, + desktop-relay, Star Nag, and usage graphs import the same telemetry client for + daemon, PTY, hook, provider, repository, IPC, relay, and product events. +- The telemetry client itself imports cohort-at-emit and consent resolution, preserving + its shutdown → burst-cap → consent → validator → capture ordering. +- Cohort, onboarding-cohort, and consent modules are also consumed through Store and + deferred IPC graphs. +- Observability status and diagnostic bundle operations are consumed by deferred core + IPC/runtime diagnostic paths and crash-feedback bundle construction. + +Crash breadcrumbs, durable breadcrumbs, lifecycle diagnostics, runtime-RPC failure UI, +updater diagnostics, TCC notice handling, and other cross-domain leaves remain in their +existing eager owners. + +## Lifecycle and order audit + +There is no supported pre-app-ready telemetry or observability event in `index.ts`. +Before readiness, the daemon-start and main-window functions only declare callbacks. +Store construction, agent subscriptions, telemetry initialization, runtime/IPC +construction, terminal startup, and window creation all occur inside or after +`app.whenReady`. + +The retained order is: + +1. browser, main-window, terminal-runtime, updater-runtime, desktop-shell, agent-hook, + and Codex capabilities load and install; +2. the telemetry/observability aggregate loads once, returns original exports, and is + installed before Store construction; +3. Store construction, hydration, browser session setup, and agent subscriptions retain + their positions; +4. `initTelemetry(store)` runs at its original point before any IPC/renderer can emit; +5. hang detection tracking follows telemetry initialization; +6. Codex trust-grant telemetry receives the same `track` identity and field mapping; +7. observability initializes before the main-process lifecycle breadcrumb; +8. both cohort classifiers initialize after that breadcrumb and before stats, account, + runtime, IPC, or renderer consumers; +9. daemon fallback and first-window consent/app-opened callbacks resolve the + fail-closed owner synchronously; and +10. committed quit still awaits telemetry shutdown, then observability shutdown, before + the second `app.quit()`. + +The `will-quit` handler can theoretically run before app readiness. It uses the +specifically named optional owner read. Before installation neither lane could have +initialized, so skipping both shutdown calls is safe. After installation it invokes the +same shutdown functions at the original positions. Live callbacks use the required +owner and fail closed if invoked before installation. + +The capability factory only returns imported identities. It does not initialize +PostHog, install an observability sink, resolve consent, capture an event, set Store +state, reset burst caps, initialize cohorts, or begin shutdown. + +## Preserved contracts + +- Telemetry consent precedence, live settings reads, opt-in/out behavior, burst caps, + validation, common properties, official-build gating, and PostHog singleton ownership + are unchanged. +- `app_opened` still emits once only after the first main-window load and effective + consent is enabled. +- Daemon startup errors retain the same classification and `daemon_start_failed` + payload; runtime-RPC failures continue through their existing eager leaf. +- Codex trust-grant tracking keeps the same callback timing and property mapping. +- Cohort and onboarding-cohort Store references initialize at the same points and retain + their never-crash fallback behavior. +- Observability remains isolated from product telemetry, retains its environment gates, + local sink ownership, lifecycle breadcrumb ordering, and tracer-before-sink shutdown. +- Desktop and headless serve use the same process-wide telemetry and observability + identities. No local, WSL, SSH, remote, folder-workspace, Git-provider, Git 2.25, or + macOS/Linux/Windows routing behavior changed. + +## A/B artifacts and hashes + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- Fresh A: `/tmp/orca-telemetry-observability-a.5x4rOo` +- Final B: `/tmp/orca-telemetry-observability-b.BLPjRb` +- A entry SHA-256: + `f6242c0955c8a600a44912e21eb0bdd8296483b859c615b36e998896892cf527` +- B entry SHA-256: + `196f22d886cf0e6d534b1ea6ed73ece9a3bf561fb55a252a47109f5b0313bbfc` + +Both sorted non-main manifests contain 786 rows / 89,303 bytes and have SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`. +Their direct diff is empty. + +The A main manifest contains 131 rows / 15,238 bytes and has SHA-256 +`afbb9536871be0705da1b21523f5fbf30ecf990a00db13026f3e83c200a247f7`. +The B main manifest contains 134 rows / 15,607 bytes and has SHA-256 +`2ef872fbaf337b2068950ff1a4593017fc64e29f9dc54fd7954b6cecf862fcb9`. + +## Emitted chunk and closure + +The retained build emits +`out/main/chunks/telemetry-observability-startup-capability-CAjC2Tep.js` at +1,054 raw bytes with SHA-256 +`579021b1805c414959206c9b7826bb69b9b0fbd1af4e78aedb03a64237569890`. +Its five direct literal relative edges are: + +- `./tui-agent-config-DMkBaphp.js` +- `../index.js` +- `./daemon-file-log-DC6dBvZO.js` +- `./logs-directory-LBNBRBdv.js` +- `./onboarding-cohort-classifier-D_FlZnhw.js` + +The `../index.js` edge is the bundler's shared-entry cycle and preserves composition-root +state rather than duplicating it. + +An inclusive Acorn AST walk followed literal relative import, export, +dynamic-import, and `require` edges. The capability closure visited 124 JavaScript +files and validated 669 edges. A separate scan of all 134 emitted-main JavaScript +files validated 717 edges. Every resolved target exists and remains beneath +`out/main`; no edge escapes the emitted directory. + +## Budget + +The previous Electron-main raw budget was 3,638,619 bytes. Lowering only that value by +the exact 16,651-byte reduction produces 3,621,968 bytes: + +`3,621,968 - 3,573,732 = 48,236` + +Preload and renderer budgets are unchanged. + +## Validation + +- Fresh A and final B `pnpm run build:electron-vite`: passed; main transforms + changed from 1,993 to 1,995, preload remained 17, and renderer remained 9,181. +- Focused capability, owner, source-boundary, telemetry client/lifecycle/consent, + burst cap, validator, cohort, onboarding cohort, install ID, IPC telemetry, + agent-hook install telemetry, Codex trust telemetry, hang telemetry, + observability architecture/bundle/upload/instrumentation/sink/redaction/tracer, + daemon startup, runtime-RPC failure, first-window, desktop/serve, + terminal/runtime startup, full runtime, shutdown checkpoint, dev-parent shutdown, + and relaunch coverage: 40 files / 1,345 passed and 1 skipped. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions + and no new bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,573,732 actual versus + 3,621,968 budgeted Electron-main bytes. +- `git diff --check`: passed. + +The telemetry consent suite intentionally emitted its existing warning for +`DO_NOT_TRACK="0"` as an unrecognized truthy value. Both production builds emitted +the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +## Remaining limitation + +The production builds and Acorn scans validate emitted relative dependency resolution +on this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, +Linux, and Windows; cross-platform packaged launch verification remains explicitly +unresolved. diff --git a/tools/benchmarks/results/phase1-terminal-runtime-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-terminal-runtime-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..213d49eedcc --- /dev/null +++ b/tools/benchmarks/results/phase1-terminal-runtime-startup-boundary-2026-07-29.md @@ -0,0 +1,210 @@ +# Phase 1 terminal-runtime startup boundary — 2026-07-29 + +**Scope:** Move Electron-main terminal startup, daemon lifecycle, provider access, pane teardown, +headless PTY registration, and first-window startup coordination behind one app-ready aggregate +capability while preserving exact PTY/provider identities, startup promises, and shutdown policy. + +## Result + +`src/main/index.ts` no longer value-imports `ipc/pty`, `daemon/daemon-init`, +`providers/local-pty-provider`, or `startup/first-window-startup-services`. It awaits one +`./startup/terminal-runtime-startup-capability` immediately after the existing browser-kernel and +main-window capabilities, installs the exact returned identities in a typed owner, and registers +the existing pane-key teardown callback once before any terminal startup. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 4,001,071 | 3,829,136 | -171,935 | 849,834 | 814,599 | -35,235 | + +The preload and every renderer static graph were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 89,303 bytes and had SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; their direct diff was +empty. + +## Production importer and side-effect audit + +Before this tranche, `index.ts` eagerly imported: + +- `killAllPty`, `clearProviderPtyState`, `getPtyIdForPaneKey`, + `registerPaneKeyTeardownListener`, `getLocalPtyProvider`, `getSshPtyProvider`, and + `registerHeadlessPtyRuntime` from `ipc/pty`; +- `initDaemonPtyProvider`, `disconnectDaemon`, and `shutdownDaemon` from + `daemon/daemon-init`; +- `LocalPtyProvider` from `providers/local-pty-provider`; and +- `startFirstWindowStartupServices` from `startup/first-window-startup-services`. + +The remaining production import graph was also audited: + +- `updater.ts` imports `killAllPty` from `ipc/pty` and is still eagerly imported by `index.ts` for + update and quit state. Consequently, `ipc/pty` and its concrete `LocalPtyProvider` dependency + were already in the eager graph independently of the removed index import. +- `attach-main-window-services.ts` imports PTY registration/provider values, but that module is + behind the previously retained main-window startup capability. +- `register-core-handlers.ts` reaches terminal IPC through the deferred core-IPC registry. +- `ssh-relay-session.ts`, `local-build-compatibility.ts`, `hydrate-local-pty-registry.ts`, and + `pty-management.ts` retain their domain-owned imports. Their desktop consumers are behind the + existing deferred window/core-IPC graphs. +- `daemon-init.ts` imports the PTY provider getters/setters to atomically adopt and replace the + live provider. No other eager index path retained `daemon-init`; moving it produced the emitted + daemon chunk and most of the measured reduction. +- `first-window-startup-services.ts` now has one production value importer: the aggregate + terminal-runtime capability. + +`ipc/pty` owns module-level provider registries, pane teardown listeners, renderer delivery state, +and the initial local provider. `daemon-init` owns module-level spawner, adapter, and restart state, +but performs filesystem, socket, process, and provider-swap work only when its exported lifecycle +functions run. The first-window startup module creates timers and abort controllers only when its +factory is invoked. The capability constructs no replacement service and triggers none of those +operations while loading. + +No PTY can be created or torn down before the app-ready callback installs the capability: +desktop PTY handlers are attached after Store/runtime construction and core IPC readiness; +headless PTY registration occurs after runtime construction and the local-provider barrier; and +the daemon startup factory is invoked later at the original startup point. An app that quits +before readiness has no PTY or daemon to clean up, so the owner exposes a nullable installed-state +read only for that early-quit path. All required consumers use the fail-closed getter, which throws +`Terminal-runtime capability must be initialized before use`. + +## Capability identity and startup order + +`createTerminalRuntimeStartupCapability` returns the exact original identities for all twelve +values: the seven PTY functions, three daemon functions, `LocalPtyProvider`, and +`startFirstWindowStartupServices`. It does not wrap constructors, providers, callbacks, or +promises. + +The retained order is: + +1. `app.whenReady` records `app-ready`. +2. Browser-kernel initialization completes. +3. Main-window capability initialization completes. +4. The terminal-runtime capability loads, is installed, and registers + `stopSyntheticTitleSpinner` as the sole pane-key teardown listener added by `index.ts`. +5. Existing browser-manager, watchdog, certificate, app identity, Store, account, runtime, IPC, + plugin, automation, and activation setup continues. +6. Runtime construction receives live thunks over the same `getLocalPtyProvider` and + `getSshPtyProvider` identities, plus the same `clearProviderPtyState` callback. The thunks still + resolve daemon swaps and current SSH relay generations at call time. +7. `startTerminalRuntimeStartupServices` invokes the same first-window factory at the original + point after mobile/RPC wiring and before macOS activation and serve branching. +8. The daemon start receives the same `AbortSignal` and + `macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode`. Daemon and hook server + startup remain parallel. +9. The returned `firstWindowReady`, `localPtyReady`, and `localPtyProviderReady` promises are + assigned directly, without wrapping, to the existing module-level barriers. Their milestone + callbacks remain unchanged. + +Daemon failure still logs the terminal-persistence fallback and emits +`daemon_start_failed` with the same classified error. Hook-server failure remains non-fatal. +Desktop rendering retains the first-window and WSL barrier, while local destructive PTY routing +retains the provider-only barrier. + +## Serve, provider, and teardown contracts + +Headless serve still: + +- awaits the managed WSL reconciliation barrier; +- awaits the exact `localPtyStartupReady` promise before registering a terminal runtime; +- passes the same runtime singleton, Store singleton, settings resolver, Codex preparation, + Claude auth preparation, and Codex resume preparation to `registerHeadlessPtyRuntime`; +- attaches offscreen browser support only under its existing display gate; +- syncs the headless window graph before RPC start; +- tests the live provider with the exact `LocalPtyProvider` class identity before marking desktop + promotion ready; and +- retains the same macOS/Linux/Windows, WSL, SSH/remote, and folder-workspace routing. + +Synthetic-title ticks and state transitions still resolve pane keys through the same live PTY +registry. Pane teardown removes spinner state exactly once before terminal startup can create a +pane. + +Committed quit still flushes stats before `killAllPty`, then starts watcher and Store cleanup. The +daemon promise remains in the same bounded teardown group after runtime-RPC shutdown begins. +Dev-parent shutdown calls the exact `shutdownDaemon` identity; normal quit calls the exact +`disconnectDaemon` identity. The second `will-quit` pass remains guarded by +`daemonDisconnectDone`. Update/relaunch, terminal scrollback capture, remote providers, Git +provider behavior, Git 2.25 compatibility, and cross-platform gates were not changed. + +## Emitted chunks and dependency closure + +The retained build emits: + +- `out/main/chunks/terminal-runtime-startup-capability-W727_99D.js`: 3,416 raw / 1,217 gzip bytes, + SHA-256 `a61862e4e6df6fc6c1e8698d9f9f4f3f86986fd8d635c726d4a1eb0e1a6dc1f6`; +- `out/main/chunks/daemon-init-DqgYWrbr.js`: 172,799 raw / 35,568 gzip bytes, SHA-256 + `05282be972ca20f754a01fd9f0b664184f829818c0cfb0cfc2ad8e2776672a67`. + +The retained `out/main/index.js` SHA-256 is +`f08e6c9ebf1881f57bb3487fe7c41ce45d1ce6cd3a7dc4ccf3b006e1ee587aac`. + +The capability's 18 direct relative dependencies are: + +- `./win32-utils-DtAFUr2N.js` +- `./fs-utils-D5115c5m.js` +- `./tui-agent-config-DMkBaphp.js` +- `./wsl-CtpKNBla.js` +- `./agent-title-core-ZQqjV6ne.js` +- `./clipboard-text-CRn8fVQZ.js` +- `./hook-service-DW83xYw6.js` +- `./terminal-tab-id-aqPw5adY.js` +- `./grok-session-paths-DGDWgbNw.js` +- `./codex-app-server-client-DB28RBY-.js` +- `./managed-agent-hook-controls-B_rRGlfA.js` +- `./codex-home-paths-RmLALm-m.js` +- `./pty-path-safety-BexQaAV3.js` +- `./worktree-id-OYDHEjE8.js` +- `../index.js` +- `./terminal-history-seed-chunks-DryYb1bm.js` +- `./headless-emulator-0yuclqag.js` +- `./daemon-init-DqgYWrbr.js` + +An Acorn AST walk from the capability visited 100 JavaScript files and validated 511 literal +relative import, export, dynamic-import, and require edges. A separate full `out/main` AST scan +checked 110 JavaScript files and 556 relative edges. Every resolved target exists beneath +`out/main`; no path escapes the emitted directory. The `../index.js` edge is the bundler's +expected shared-module cycle, with `index.js` dynamically entering the capability. + +## Budget + +The prior Electron-main raw budget was 4,049,307 bytes. Lowering only that budget by the exact +171,935-byte raw reduction produces 3,877,372 bytes and leaves exactly 48,236 bytes of headroom +over the 3,829,136-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed; main-module transforms changed + from 1,982 to 1,984 while preload remained 17 modules and renderer remained 9,181 modules. +- Capability, owner, source-boundary, first-window startup, desktop/serve readiness, runtime, + complete focused PTY IPC, daemon init/adapter/router/spawner/degraded provider, macOS daemon + login-session, WSL terminal host, SSH relay generation/delivery/error, synthetic-title, + quit-deadline, dev-parent shutdown, window attachment, and core-IPC coverage: 38 files / 936 + passed and 6 skipped. +- Updater/headless-serve install, update handoff, headless terminal layouts/hydration, CLI/WSL + boundary and barrier, WSL environment, and hook-relay coverage: 11 files / 172 tests passed. +- Focused capability, owner, boundary, first-window, desktop, serve, and RPC-failure subset: 8 + files / 61 tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,829,136 actual versus 3,877,372 budgeted + Electron-main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/results/phase1-terminal-view-attributes-startup-boundary-2026-07-30.md b/tools/benchmarks/results/phase1-terminal-view-attributes-startup-boundary-2026-07-30.md new file mode 100644 index 00000000000..c96ef2b24d9 --- /dev/null +++ b/tools/benchmarks/results/phase1-terminal-view-attributes-startup-boundary-2026-07-30.md @@ -0,0 +1,177 @@ +# Phase 1 terminal view-attributes startup boundary — 2026-07-30 + +## Result + +The retained change moves the app-start publication path out of +`components/terminal-pane/terminal-appearance.ts` into the concrete +`components/terminal-pane/terminal-view-start.ts` leaf. `App.tsx` still publishes immediately +after the awaited settings read and before the remaining hydration chain. + +| Static closure | A raw | B raw | Raw change | A gzip | B gzip | Gzip change | A JS | B JS | A CSS | B CSS | +| ---------------- | --------: | --------: | ---------: | --------: | --------: | ----------: | ---: | ---: | ----: | ----: | +| Main window | 8,798,746 | 8,450,230 | -348,516 | 1,969,591 | 1,883,919 | -85,672 | 292 | 292 | 2 | 2 | +| Dashboard popout | 4,507,253 | 4,507,253 | 0 | 984,615 | 984,615 | 0 | 77 | 77 | 2 | 2 | +| Web renderer | 4,360,652 | 4,360,652 | 0 | 928,352 | 928,347 | -5 | 33 | 33 | 1 | 1 | + +No renderer entry raw size or JavaScript/CSS count increased. Electron main remained 776,873 +raw / 174,092 gzip and preload remained 130,798 raw / 20,642 gzip. + +## Importer and lifecycle audit + +Before the edit, the production importers of `terminal-appearance.ts` were: + +- `App.tsx`, only for `publishTerminalViewAttributesAtAppStart`; +- `use-terminal-pane-lifecycle.ts`, for the later pane-owner + `applyTerminalAppearance`; +- `TerminalSettingsPreview.tsx`, for pure theme composition; and +- the already-lazy `AgentTerminalPreview.tsx`, also for pure theme composition. + +The publisher has one module-global dedupe owner in +`terminal-view-attributes-publisher.ts`. The retained leaf and +`terminal-appearance.ts` both import that same module, so app-start publication and the later +pane-mount application still share one cache and one publish function identity. +`terminal-appearance.ts` re-exports `composeActiveTerminalTheme`, `hexToRgba`, and +`isHexColor`, preserving existing preview and test import identities while retaining all +pane-manager, fit, transport resize, mode-2031, font, cursor, and style behavior at the +existing lifecycle owner. + +The source path relevant to startup is now: + +`App.tsx -> terminal-view-start.ts -> terminal-theme.ts / terminal-themes-data.ts / +terminal-view-attributes-publisher.ts` + +The new leaf has no source import of pane manager, terminal pane lifecycle, +`sync-runtime-graph`, PTY connection/dispatcher/transport, or +`applyTerminalAppearance`. `sync-runtime-graph.ts` continues to import terminal-theme policy +directly, and the root store continues to import its terminal slice, which continues to +schedule runtime-graph sync. Those owners and paths were not moved. + +The A Vite manifest showed both direct emitted paths: + +- `index.html -> assets/terminal-appearance-CQ9bghuq.js` +- `assets/App-D3NC2vQN.js -> assets/terminal-appearance-CQ9bghuq.js` + +That A chunk was 363,645 raw / 90,080 gzip with SHA-256 +`9cf0c348f8f17fdfe59a851d073e40d94b9068665c3a611dc00ea71ed1bff3f2`. It imported +`store-C4ip2Tem.js` and contained the startup publication together with pane/mobile-fit, +Windows/runtime, and terminal lifecycle implementation code because of the shared/circular +allocation. + +The B manifest instead has: + +`index.html -> assets/terminal-view-start-tVBEoFhs.js` + +The B leaf chunk is 15,136 raw / 4,427 gzip with SHA-256 +`c656f95dd1454a0c2a660fb9e480ceaed25fc6ca7fc1a264a5e81041f7d703da`. +No `terminal-appearance` key or file is statically reachable from the B main entry. The new +chunk still imports the already-eager root-store shared chunk because of Rollup's existing +shared allocation, but its source graph does not import the store, pane manager, runtime +graph, or PTY implementations. Popout and web do not statically reach the new leaf. + +A preliminary, unretained `terminal-view-attributes-startup` filename increased popout and +web raw closures by 13 bytes each because Vite embedded the longer basename in shared preload +maps. The retained `terminal-view-start` name is concrete and matches the former +`terminal-appearance` basename length, eliminating that cross-entry filename tax. + +## Behavioral preservation + +`App.tsx` still awaits `fetchSettings`, reads the same `useAppStore` state, computes the same +system dark/light preference, and publishes before keybindings, persisted UI, restored +terminal, and pane-mount work. Null settings remain a no-op. Built-in and custom terminal +themes, overrides, background/cursor opacity composition, and published color-scheme mode +use the same implementations. + +Hidden-at-launch PTYs therefore retain the pre-pane OSC 10/11 answer publication. A later +`applyTerminalAppearance` call uses the same publisher cache and remains a deduped no-op when +the attributes are unchanged. No terminal restore, local/WSL/SSH/relay/runtime routing, +folder-workspace, provider, input, theme, or pane lifecycle policy changed. + +## Fresh A/B artifacts and hashes + +The paths below were ephemeral local build directories used during measurement, +not durable artifacts. The recorded hashes, byte counts, manifests, and +conclusions in this report are the portable evidence. + +- A: `/tmp/orca-terminal-view-startup-a.LZJNf7` +- B: `/tmp/orca-terminal-view-startup-b2.rlu8pp` + +Electron entry SHA-256 values were identical: + +- main A/B: + `6e75b9d4862f1219c23f16d6c920004167d7dae1995498caaacc5354a9d8f8fd` +- preload A/B: + `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` + +Sorted full-tree manifests contain each relative path and file SHA-256: + +| Output tree | A rows | A manifest SHA-256 | B rows | B manifest SHA-256 | Changed rows | +| ----------- | -----: | ------------------------------------------------------------------ | -----: | ------------------------------------------------------------------ | -----------: | +| Main | 184 | `f4a845ee1e5ebc80c83b4fd10fc1f61ab52e36f82e0de80e308ebdc6cd1a4e85` | 184 | `f4a845ee1e5ebc80c83b4fd10fc1f61ab52e36f82e0de80e308ebdc6cd1a4e85` | 0 | +| Preload | 1 | `41ff6755a20a376674371331e9787711cb1b1f32c46582c7cd99880a4c07baf6` | 1 | `41ff6755a20a376674371331e9787711cb1b1f32c46582c7cd99880a4c07baf6` | 0 | +| Renderer | 787 | `c20ec9b73bdbc121aff57661290f3332d2b02d6a3c96a7f323883ff1bec09618` | 787 | `61e5a85c102d81f664360642655e3bd22aaa3a9787e1b0e200078c467cec4504` | 58 | + +The Vite manifest contains 778 entries in each build: + +- A SHA-256: + `471f551a8431804bccd958cdf7ba831451e7560ae1422e5ade93030e0fcad90c` +- B SHA-256: + `4142e98fc5f05ffd55e1c08e8cc4d0a4afb2997f95edafeb99c39dac71a2839b` + +Sorted static-closure manifests contain each JavaScript/CSS path and file SHA-256: + +| Entry | A rows | A closure-manifest SHA-256 | B rows | B closure-manifest SHA-256 | +| ------ | -----: | ------------------------------------------------------------------ | -----: | ------------------------------------------------------------------ | +| Main | 294 | `dd6c97cc5a75469e0c34cf13ba151d20193bf1491916962f956fb587fd8401a5` | 294 | `e1c1b5d9070b00ec0e0a3c77823d046a3c891b4a3f2ab0af9cef8d79533fc20c` | +| Popout | 79 | `81498ed245318f0b027106a450854db8243163b95006f23b10e7fb1c38e15824` | 79 | `e2600b6f0699021ac6f236777514e67430ffb24c61cd5ace8c8144f13e41b615` | +| Web | 34 | `8c5c1a2232fd581fe45af47a58db0bdded2ff7ec8e97696debbcc2eef55417d1` | 34 | `1257316624b320c0fe1e398727077a55b50b85b9dbd528a7b9fbd40095eb8c65` | + +## Emitted validation + +The complete B Vite manifest validation covered: + +- 778 manifest entries; +- 6,249 static import-key edges; +- 213 dynamic import-key edges; and +- 860 emitted file, CSS, and asset references. + +Every manifest key and emitted target exists beneath `out/renderer`. An Acorn parse of all +702 emitted JavaScript files validated 6,491 literal relative import, export, and dynamic +import edges; no target was missing and no edge escaped the renderer output. The inclusive +Acorn walk from `assets/terminal-view-start-tVBEoFhs.js` reached 51 JavaScript files and +validated 196 edges. + +## Budget ratchet + +Only `renderer-index.maxRawBytes` changed, from 8,848,553 to 8,500,037, exactly matching the +retained 348,516-byte reduction. Its headroom remains: + +`8,500,037 - 8,450,230 = 49,807` + +The other renderer budgets and all JavaScript/CSS count budgets are unchanged. Electron main +remains budgeted at 825,109: + +`825,109 - 776,873 = 48,236` + +No budget increased. + +## Tests and gates + +- Fresh A and final B `pnpm run build:electron-vite`: passed. +- Focused terminal appearance/theme/publisher/source-boundary suite: 5 files, 61 tests passed. +- Broader app startup, terminal pane lifecycle, and runtime-graph suite: 14 files, 174 tests + passed. +- `pnpm run typecheck:web`: passed. +- Targeted `pnpm exec oxlint --deny-warnings`: passed. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed. +- `git diff --check`: passed. + +## Remaining limitations + +The production builds and emitted validation ran in this macOS worktree. No packaged launch +smoke was run on macOS, Linux, or Windows, so packaged cross-platform launch coverage remains +unresolved. The retained boundary does not partition the already-eager root store or +runtime-graph graph; it only removes the startup-only App dependency on the pane appearance +lifecycle module. diff --git a/tools/benchmarks/results/phase1-updater-runtime-startup-boundary-2026-07-29.md b/tools/benchmarks/results/phase1-updater-runtime-startup-boundary-2026-07-29.md new file mode 100644 index 00000000000..839dba7fbab --- /dev/null +++ b/tools/benchmarks/results/phase1-updater-runtime-startup-boundary-2026-07-29.md @@ -0,0 +1,214 @@ +# Phase 1 updater-runtime startup boundary — 2026-07-29 + +**Scope:** Move the shared updater singleton and remote-server updater adapter behind one +app-ready aggregate capability while retaining a minimal synchronous quit-state seam for +pre-readiness activation and Electron quit policy. + +## Result + +`src/main/index.ts` no longer value-imports `updater.ts` or +`runtime/remote-server-updater.ts`. It awaits one +`./startup/updater-runtime-startup-capability` after the retained browser, main-window, and +terminal-runtime capabilities, installs the exact returned identities in a typed owner, and +configures the remote adapter before any runtime RPC server or updater method can observe it. + +| Surface | Before raw | After raw | Raw change | Before gzip | After gzip | Gzip change | +| ------------- | ---------: | --------: | ---------: | ----------: | ---------: | ----------: | +| Electron main | 3,829,136 | 3,761,675 | -67,461 | 814,599 | 801,485 | -13,114 | + +The preload and every renderer static graph were byte-for-byte unchanged: + +| Surface | Raw bytes | Gzip bytes | JavaScript | CSS | +| ---------------- | --------: | ---------: | ---------: | --: | +| Electron preload | 130,798 | 20,642 | 1 | 0 | +| Main renderer | 9,815,193 | 2,190,210 | 294 | 3 | +| Dashboard popout | 5,894,305 | 1,297,290 | 83 | 3 | +| Web renderer | 4,360,776 | 928,388 | 33 | 1 | + +The sorted SHA-256 manifests for all 786 files outside `out/main` were identical. Each manifest +contained 786 rows / 89,303 bytes and had SHA-256 +`e7fed74bb51c30b41084a2a5d052f5dc0773a696552f1a8e051d363ed4be762a`; their direct diff was +empty. + +## Production importer and lifecycle audit + +Before this tranche, `index.ts` eagerly imported: + +- `checkForRemoteServerUpdate`; +- `checkForUpdatesFromMenu`; +- `downloadRemoteServerUpdate`; +- `getRemoteServerUpdaterSnapshot`; +- `installRemoteServerUpdate`; +- `isQuittingForUpdate`; +- `resolveUpdateInstallMode`; and +- `configureRemoteServerUpdater`. + +The complete production graph showed: + +- `attach-main-window-services.ts` imports the same updater singleton for setup, status, download, + install, dismissal, nudge, and menu-check behavior. That importer is already behind the retained + main-window startup capability. +- Main-window core IPC reaches those attached updater functions only after the deferred core-IPC + registry and window creation. +- Runtime RPC status and updater methods import `runtime/remote-server-updater.ts`, whose adapter + intentionally starts unavailable and throws `remote_update_manual_required` for mutating + operations until configured. +- The runtime RPC method graph is loaded after runtime construction, well after the new capability + configures the adapter. +- `updater.ts` imports `ipc/pty` for the committed native-install cleanup. Moving the updater + therefore also removes that remaining PTY entry path while preserving the same `killAllPty` + identity inside the updater chunk. +- Local build feed/switch, nudge, prerelease, auto-updater loading, diagnostics, macOS install, + update watchdog, and serve handoff modules remain owned by the same updater singleton. + +The updater module owns all existing status, retry, feed, nudge, download, install, and relaunch +state. The capability constructs no replacement updater and performs no setup; it returns the +seven original updater/adapter function identities. The main-window capability may evaluate the +same updater module first through its attached-services graph, but both capabilities receive the +same ESM singleton and state. + +## Minimal pre-ready quit-state seam + +`isQuittingForUpdate` was the only updater value required before app readiness: + +- the second-instance/desktop activation path checks it synchronously before loading core IPC and + checks it again before opening a window; +- renderer teardown classification checks it synchronously; +- `before-quit` uses it for update lifecycle diagnostics; and +- `will-quit` snapshots it before PTY, RPC, daemon, Store, and telemetry cleanup. + +Those call sites cannot become asynchronous. The boolean now lives in the specifically named +`updater-quit-state.ts` module, which starts false and exposes only +`isQuittingForUpdate`, `markUpdateInstallQuitInProgress`, and +`clearUpdateInstallQuitInProgress`. `index.ts` eagerly imports only the read function. +`updater.ts` marks the same state at the exact former assignment immediately before update cleanup +and native installer invocation, clears it only through the existing recovery/reset path, and +re-exports the read function for API compatibility. + +The initial state remains fail-closed: activation is not suppressed until an update install has +explicitly marked the shared state. Committed installs still keep the state true so macOS dock +activation, second-instance activation, tray reopen, and reload recovery cannot resurrect the old +bundle. Pre-native and uncommitted failures still clear it through the same reset path. + +## Capability, configuration, and identity order + +The retained order is: + +1. Module startup assigns `process.env.ORCA_APP_VERSION = app.getVersion()` before any dynamic + updater load. PTY children, the daemon, and the remote unavailable snapshot retain the same + version source and fallback. +2. `app.whenReady` records `app-ready`. +3. Browser-kernel initialization completes. +4. Main-window capability initialization completes. +5. Terminal-runtime capability installation and its sole pane-teardown registration complete. +6. The updater-runtime capability loads and returns the exact six updater identities plus the + exact `configureRemoteServerUpdater` identity. +7. The exact capability object is installed in the fail-closed owner. +8. The remote adapter is synchronously configured with the exact + `getRemoteServerUpdaterSnapshot`, `checkForRemoteServerUpdate`, + `downloadRemoteServerUpdate`, and `installRemoteServerUpdate` identities. +9. Browser-manager, Store, account, runtime, plugin, mobile, and runtime RPC startup continues. + +The updater owner throws `Updater-runtime capability must be initialized before use` if a required +post-ready consumer runs prematurely. Remote RPC construction occurs after configuration, so +status, check, download, and install cannot observe the unavailable adapter during supported +startup. The adapter module itself retains its original unavailable behavior for genuinely +unconfigured consumers and tests. + +## Desktop, serve, and update policy + +Menu and tray manual checks still call `ensureAutoUpdaterConfigured` first, then call the exact +`checkForUpdatesFromMenu` identity with the same optional prerelease/perf options. +`attachMainWindowServices` still owns the pending auto-updater setup callback and receives the +exact update-install mode: + +- desktop: `interactive`; +- supervised headless serve: `supervised-headless-serve`; and +- unsupervised headless serve: `unsupported-headless-serve`. + +Serve-to-desktop promotion does not change the headless install policy. Remote status/check/ +download/install methods still report the same app version, runtime ID, support reason, updater +status, target version, and accepted result. Automatic checks, local build feed selection, +downgrade handling, nudge state, prerelease fallback, retry cadence, release diagnostics, and +renderer status broadcasts remain inside the same singleton. + +Update installation still preserves agent credentials through the existing attached-window +callback, invokes native `quitAndInstall` before PTY destruction, calls the same `killAllPty`, +removes close listeners only after native acceptance, and retains the serve-supervisor handoff and +exit watchdog gates. `before-quit` and `will-quit` ordering, daemon/RPC teardown, relaunch +callbacks, macOS dock suppression, Windows/Linux native behavior, SSH/remote, WSL, +folder-workspace, Git-provider, and Git 2.25 behavior were not changed. + +## Emitted chunks and dependency closure + +The retained build emits: + +- `out/main/chunks/updater-runtime-startup-capability-CFV-Nyy-.js`: 1,364 raw / 530 gzip bytes, + SHA-256 `cd17bc8d124de75543b021a87e50c7c769451673f01d1b250b7cd678795f7514`; +- `out/main/chunks/updater-DiHIcp8X.js`: 69,929 raw / 13,612 gzip bytes, SHA-256 + `97333c856ab3e6007ceca1a43fddfd906310fd7ca4d888e2f3ae1468b70baef0`. + +The retained `out/main/index.js` SHA-256 is +`de4728ad2aa99b357b9ee5dbd4589f3d709908ab424fa45118bc08266bc63b9a`. + +The capability's 16 direct relative dependencies are: + +- `./win32-utils-DtAFUr2N.js` +- `./fs-utils-D5115c5m.js` +- `./tui-agent-config-DMkBaphp.js` +- `./wsl-CtpKNBla.js` +- `./terminal-view-attributes-D2Y73h-d.js` +- `./clipboard-text-CRn8fVQZ.js` +- `./hook-service-DW83xYw6.js` +- `./terminal-tab-id-aqPw5adY.js` +- `./grok-session-paths-DGDWgbNw.js` +- `./codex-app-server-client-DB28RBY-.js` +- `./managed-agent-hook-controls-B_rRGlfA.js` +- `./codex-home-paths-RmLALm-m.js` +- `./pty-path-safety-BexQaAV3.js` +- `./worktree-id-OYDHEjE8.js` +- `../index.js` +- `./updater-DiHIcp8X.js` + +An Acorn AST walk from the capability visited 104 JavaScript files and validated 545 literal +relative import, export, dynamic-import, and require edges. A separate full `out/main` AST scan +checked 114 JavaScript files and 591 relative edges. Every resolved target exists beneath +`out/main`; no path escapes the emitted directory. The `../index.js` edge is the bundler's +expected shared-module cycle and carries shared entry state, including the narrow quit-state +singleton. + +## Budget + +The prior Electron-main raw budget was 3,877,372 bytes. Lowering only that budget by the exact +67,461-byte raw reduction produces 3,809,911 bytes and leaves exactly 48,236 bytes of headroom +over the 3,761,675-byte entry. Preload and renderer budgets are unchanged. + +## Validation + +- Fresh production A and B `pnpm run build:electron-vite`: passed; main-module transforms changed + from 1,984 to 1,987 while preload remained 17 modules and renderer remained 9,181 modules. +- Capability, owner, source-boundary, quit-state, full updater variants, remote adapter/RPC, + runtime RPC startup, create/attach window, main-window boundary, menu/tray, macOS activation, + desktop/serve activation/readiness/stdout, update handoff, PTY cleanup, auth preservation, + relaunch, quit policy, terminal boundary, desktop startup, and core-IPC coverage: 41 files / 915 + passed and 1 skipped. +- Focused capability, owner, boundary, quit-state, updater, remote adapter/RPC, main-window + boundary, and desktop ordering subset: 9 files / 121 tests passed. +- `pnpm run typecheck:node`: passed with no diagnostics. +- Targeted `pnpm exec oxlint --deny-warnings`: passed with no diagnostics. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new + bypasses. +- `pnpm run check:electron-bundle-budgets`: passed at 3,761,675 actual versus 3,809,911 budgeted + Electron-main bytes. +- `git diff --check`: passed with no whitespace errors. + +## Warnings and remaining limitation + +Both production builds emitted the same existing CSS optimizer warnings for +`::highlight(markdown-preview-search-match)` and +`::highlight(markdown-preview-search-active-match)`. + +The production build and dependency-closure checks prove packaged-relative emitted resolution on +this macOS worktree. This tranche did not run packaged-ASAR launch smokes on macOS, Linux, and +Windows. Cross-platform packaged launch verification remains explicitly unresolved. diff --git a/tools/benchmarks/startup-time-bench.mjs b/tools/benchmarks/startup-time-bench.mjs index add2bfc234f..caded50cee3 100644 --- a/tools/benchmarks/startup-time-bench.mjs +++ b/tools/benchmarks/startup-time-bench.mjs @@ -13,7 +13,7 @@ * [--iterations 5] [--files 28000] [--fixture-dir ] * [--state-profile none|restored-local-tabs] [--session-tabs 200] * [--github-repos 3] [--gh-hang-ms 30000] - * [--wait-for-event renderer-startup-hydration-done] + * [--wait-for-event renderer-shell-painted] * [--exe ] [--timeout-ms 240000] * * Issue #7225 freeze reproduction: `--github-repos N` seeds N git repos with @@ -38,75 +38,18 @@ import { import { createRequire } from 'node:module' import os from 'node:os' import { delimiter, join, resolve } from 'node:path' +import { + deriveStartupPhases, + parseStartupLine, + STARTUP_EVENT_CLOCK_SEMANTICS +} from './startup-time-measurements.mjs' +import { sanitizeBenchmarkArtifactHomePaths } from './benchmark-artifact-home-sanitizer.mjs' +import { parseStartupBenchmarkArgs } from './startup-time-benchmark-arguments.mjs' const scriptDir = import.meta.dirname const repoRoot = resolve(scriptDir, '..', '..') const require = createRequire(import.meta.url) -function parseArgs(argv) { - const args = { - label: 'run', - iterations: 5, - files: 28000, - fixtureDir: null, - exe: null, - timeoutMs: 240000, - stateProfile: 'none', - sessionTabs: 0, - githubRepos: 0, - ghHangMs: 0, - waitForEvent: 'did-finish-load', - // How long the app stays alive after did-finish-load before the harness - // kills it. Raise to let background work (e.g. the async win32 ACL grant) - // complete the way it would in a real session. - lingerMs: 500 - } - for (let i = 2; i < argv.length; i++) { - const next = () => argv[++i] - switch (argv[i]) { - case '--label': - args.label = next() - break - case '--iterations': - args.iterations = Number(next()) - break - case '--files': - args.files = Number(next()) - break - case '--fixture-dir': - args.fixtureDir = next() - break - case '--exe': - args.exe = next() - break - case '--timeout-ms': - args.timeoutMs = Number(next()) - break - case '--state-profile': - args.stateProfile = next() - break - case '--session-tabs': - args.sessionTabs = Number(next()) - break - case '--github-repos': - args.githubRepos = Number(next()) - break - case '--gh-hang-ms': - args.ghHangMs = Number(next()) - break - case '--wait-for-event': - args.waitForEvent = next() - break - case '--linger-ms': - args.lingerMs = Number(next()) - break - default: - throw new Error(`Unknown argument: ${argv[i]}`) - } - } - return args -} - /** * Build a userData tree shaped like a real long-lived profile. The file count * drives the win32 icacls walk cost; contents are irrelevant, so files are @@ -400,29 +343,6 @@ function killProcessTree(proc) { } } -function parseStartupLine(line) { - const match = /^\[startup\] (\S+)(.*)$/.exec(line) - if (!match) { - return null - } - const details = {} - const detailText = match[2].trim() - if (detailText) { - for (const pair of detailText.match(/(\S+?)=("[^"]*"|\S+)/g) ?? []) { - const eq = pair.indexOf('=') - const key = pair.slice(0, eq) - let value = pair.slice(eq + 1) - try { - value = JSON.parse(value) - } catch { - // keep raw string - } - details[key] = value - } - } - return { event: match[1], details } -} - function runIteration({ exe, timeoutMs, lingerMs, waitForEvent, launchEnv }) { return new Promise((resolvePromise) => { // Why: npm's `electron` package exposes the platform-specific executable; @@ -475,82 +395,6 @@ function runIteration({ exe, timeoutMs, lingerMs, waitForEvent, launchEnv }) { }) } -function eventTime(events, name, key) { - const entry = events.find((event) => event.event === name) - if (!entry) { - return null - } - return key === 't' - ? typeof entry.details.t === 'number' - ? entry.details.t - : null - : entry.harnessMs -} - -function derivePhases(events) { - const aclStart = eventTime(events, 'acl-grant-start', 't') - const aclDone = eventTime(events, 'acl-grant-done', 't') - return { - startupJsonParseMs: delta( - events, - 'persistence-json-parse-start', - 'persistence-json-parse-done' - ), - startupStoreLoadMs: delta(events, 'persistence-load-start', 'persistence-load-done'), - spawnToAppReady: eventTime(events, 'app-ready', 'harness'), - appReadyToServices: delta(events, 'app-ready', 'services-initialized'), - servicesToI18n: delta(events, 'services-initialized', 'i18n-ready'), - i18nToOpenWindow: delta(events, 'i18n-ready', 'open-main-window-start'), - daemonInitMs: delta(events, 'daemon-init-start', 'daemon-init-done'), - aclGrantMs: aclStart !== null && aclDone !== null ? aclDone - aclStart : null, - windowCreatedToLoadStart: delta(events, 'window-created', 'load-start'), - windowCreatedToLoaded: delta(events, 'window-created', 'did-finish-load'), - totalToWindowCreated: eventTime(events, 'window-created', 'harness'), - totalToDidFinishLoad: eventTime(events, 'did-finish-load', 'harness'), - didFinishLoadToWorkspaceReady: delta( - events, - 'did-finish-load', - 'renderer-startup-hydration-done' - ), - totalToWorkspaceReady: eventTime(events, 'renderer-startup-hydration-done', 'harness'), - rendererReconnectTerminalsMs: - eventDetailsNumber(events, 'renderer-reconnect-terminals-done', 'durationMs') ?? - delta( - events, - 'renderer-first-window-services-await-done', - 'renderer-reconnect-terminals-done' - ), - // Worst single main-thread stall observed by the event-loop probe — the - // direct measurement of issue #7225's "Not Responding" freeze. - maxEventLoopStallMs: maxEventDetailsNumber(events, 'event-loop-stall', 'maxGapMs') - } -} - -function maxEventDetailsNumber(events, name, key) { - let max = null - for (const event of events) { - if (event.event !== name) { - continue - } - const value = event.details[key] - if (typeof value === 'number' && (max === null || value > max)) { - max = value - } - } - return max -} - -function eventDetailsNumber(events, name, key) { - const value = events.find((event) => event.event === name)?.details[key] - return typeof value === 'number' ? value : null -} - -function delta(events, from, to) { - const a = eventTime(events, from, 't') - const b = eventTime(events, to, 't') - return a !== null && b !== null ? b - a : null -} - function median(values) { const usable = values.filter((value) => typeof value === 'number').sort((a, b) => a - b) if (usable.length === 0) { @@ -560,15 +404,6 @@ function median(values) { return usable.length % 2 ? usable[mid] : (usable[mid - 1] + usable[mid]) / 2 } -// Results are committed as PR evidence — keep home-anchored paths out of them. -function sanitizeLocalPath(value) { - if (typeof value !== 'string') { - return value - } - const home = os.homedir() - return value.startsWith(home) ? `~${value.slice(home.length)}` : value -} - function formatMs(value) { if (value === null) { return 'n/a' @@ -577,7 +412,7 @@ function formatMs(value) { } async function main() { - const args = parseArgs(process.argv) + const args = parseStartupBenchmarkArgs(process.argv) if (!['none', 'restored-local-tabs'].includes(args.stateProfile)) { throw new Error(`Unknown state profile: ${args.stateProfile}`) } @@ -617,7 +452,7 @@ async function main() { waitForEvent: args.waitForEvent, launchEnv }) - const phases = derivePhases(result.events) + const phases = deriveStartupPhases(result.events) iterations.push({ ...result, phases }) console.log( `${result.outcome} total=${formatMs(phases.totalToDidFinishLoad)} acl=${formatMs(phases.aclGrantMs)} maxStall=${formatMs(phases.maxEventLoopStallMs)}` @@ -639,22 +474,23 @@ async function main() { writeFileSync( outPath, JSON.stringify( - { + sanitizeBenchmarkArtifactHomePaths({ label: args.label, platform: process.platform, arch: process.arch, cpus: os.cpus()[0]?.model, - fixtureDir: sanitizeLocalPath(fixtureDir), + fixtureDir, fixtureFiles: args.files, stateProfile: args.stateProfile, sessionTabs: args.sessionTabs, githubRepos: args.githubRepos, ghHangMs: args.ghHangMs, waitForEvent: args.waitForEvent, - exe: sanitizeLocalPath(args.exe), + eventClockSemantics: STARTUP_EVENT_CLOCK_SEMANTICS, + exe: args.exe, iterations, summaryMedianMs: summary - }, + }), null, 2 ) diff --git a/tools/benchmarks/startup-time-benchmark-arguments.mjs b/tools/benchmarks/startup-time-benchmark-arguments.mjs new file mode 100644 index 00000000000..ae20abdd7d0 --- /dev/null +++ b/tools/benchmarks/startup-time-benchmark-arguments.mjs @@ -0,0 +1,61 @@ +export function parseStartupBenchmarkArgs(argv) { + const args = { + label: 'run', + iterations: 5, + files: 28000, + fixtureDir: null, + exe: null, + timeoutMs: 240000, + stateProfile: 'none', + sessionTabs: 0, + githubRepos: 0, + ghHangMs: 0, + waitForEvent: 'did-finish-load', + // Keeps new-build trailing milestones observable after the baseline-compatible boundary. + lingerMs: 500 + } + for (let i = 2; i < argv.length; i++) { + const next = () => argv[++i] + switch (argv[i]) { + case '--label': + args.label = next() + break + case '--iterations': + args.iterations = Number(next()) + break + case '--files': + args.files = Number(next()) + break + case '--fixture-dir': + args.fixtureDir = next() + break + case '--exe': + args.exe = next() + break + case '--timeout-ms': + args.timeoutMs = Number(next()) + break + case '--state-profile': + args.stateProfile = next() + break + case '--session-tabs': + args.sessionTabs = Number(next()) + break + case '--github-repos': + args.githubRepos = Number(next()) + break + case '--gh-hang-ms': + args.ghHangMs = Number(next()) + break + case '--wait-for-event': + args.waitForEvent = next() + break + case '--linger-ms': + args.lingerMs = Number(next()) + break + default: + throw new Error(`Unknown argument: ${argv[i]}`) + } + } + return args +} diff --git a/tools/benchmarks/startup-time-benchmark-arguments.test.mjs b/tools/benchmarks/startup-time-benchmark-arguments.test.mjs new file mode 100644 index 00000000000..1167b932579 --- /dev/null +++ b/tools/benchmarks/startup-time-benchmark-arguments.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { parseStartupBenchmarkArgs } from './startup-time-benchmark-arguments.mjs' + +describe('parseStartupBenchmarkArgs', () => { + it('defaults to the milestone available in older baseline builds', () => { + expect(parseStartupBenchmarkArgs(['node', 'startup-time-bench.mjs']).waitForEvent).toBe( + 'did-finish-load' + ) + }) + + it('allows callers to wait explicitly for the shell paint milestone', () => { + expect( + parseStartupBenchmarkArgs([ + 'node', + 'startup-time-bench.mjs', + '--wait-for-event', + 'renderer-shell-painted' + ]).waitForEvent + ).toBe('renderer-shell-painted') + }) +}) diff --git a/tools/benchmarks/startup-time-measurements.mjs b/tools/benchmarks/startup-time-measurements.mjs new file mode 100644 index 00000000000..ecc2ab48b8e --- /dev/null +++ b/tools/benchmarks/startup-time-measurements.mjs @@ -0,0 +1,131 @@ +export const STARTUP_EVENT_CLOCK_SEMANTICS = { + harnessMs: 'Milliseconds on the benchmark parent clock at complete-line receipt.', + t: 'Milliseconds on the Electron main process performance.now() clock.', + rendererT: 'Milliseconds on the main-window renderer performance.now() clock.' +} + +function parseDetails(detailText) { + const details = {} + const pairPattern = /(?:^|\s)([A-Za-z][\w-]*)=(.*?)(?=\s+[A-Za-z][\w-]*=|$)/g + for (const match of detailText.matchAll(pairPattern)) { + let value = match[2] + try { + value = JSON.parse(value) + } catch { + // Keep diagnostic values emitted without JSON encoding as strings. + } + details[match[1]] = value + } + return details +} + +export function parseStartupLine(line) { + const match = /^\[(bootstrap|startup)\] (\S+)(.*)$/.exec(line) + if (!match) { + return null + } + return { + source: match[1], + event: match[2], + details: parseDetails(match[3].trim()) + } +} + +function eventTime(events, name, clock) { + const entry = events.find((event) => event.event === name) + if (!entry) { + return null + } + if (clock === 'harnessMs') { + return typeof entry.harnessMs === 'number' ? entry.harnessMs : null + } + const value = entry.details[clock] + return typeof value === 'number' ? value : null +} + +function clockDelta(events, from, to, clock = 't') { + const startedAt = eventTime(events, from, clock) + const finishedAt = eventTime(events, to, clock) + return startedAt !== null && finishedAt !== null ? finishedAt - startedAt : null +} + +function eventDetailsNumber(events, name, key) { + const value = events.find((event) => event.event === name)?.details[key] + return typeof value === 'number' ? value : null +} + +function maxEventDetailsNumber(events, name, key) { + let max = null + for (const event of events) { + if (event.event !== name) { + continue + } + const value = event.details[key] + if (typeof value === 'number' && (max === null || value > max)) { + max = value + } + } + return max +} + +export function deriveStartupPhases(events) { + const aclStart = eventTime(events, 'acl-grant-start', 't') + const aclDone = eventTime(events, 'acl-grant-done', 't') + return { + spawnToBundleEnterMs: eventTime(events, 'bundle-enter', 'harnessMs'), + synchronousBundleAndDependencyEvaluationMs: clockDelta( + events, + 'bundle-enter', + 'bundle-evaluation-complete' + ), + bundleEvaluationCompleteToAppReadyMs: clockDelta( + events, + 'bundle-evaluation-complete', + 'app-ready' + ), + appReadyToServicesInitializedMs: clockDelta(events, 'app-ready', 'services-initialized'), + servicesInitializedToFirstReactCommitMs: clockDelta( + events, + 'services-initialized', + 'renderer-first-react-commit' + ), + firstReactCommitToShellPaintedMs: clockDelta( + events, + 'renderer-first-react-commit', + 'renderer-shell-painted', + 'rendererT' + ), + totalToFirstReactCommitMs: eventTime(events, 'renderer-first-react-commit', 'harnessMs'), + totalToShellPaintedMs: eventTime(events, 'renderer-shell-painted', 'harnessMs'), + startupJsonParseMs: clockDelta( + events, + 'persistence-json-parse-start', + 'persistence-json-parse-done' + ), + startupStoreLoadMs: clockDelta(events, 'persistence-load-start', 'persistence-load-done'), + spawnToAppReady: eventTime(events, 'app-ready', 'harnessMs'), + appReadyToServices: clockDelta(events, 'app-ready', 'services-initialized'), + servicesToI18n: clockDelta(events, 'services-initialized', 'i18n-ready'), + i18nToOpenWindow: clockDelta(events, 'i18n-ready', 'open-main-window-start'), + daemonInitMs: clockDelta(events, 'daemon-init-start', 'daemon-init-done'), + aclGrantMs: aclStart !== null && aclDone !== null ? aclDone - aclStart : null, + windowCreatedToLoadStart: clockDelta(events, 'window-created', 'load-start'), + windowCreatedToLoaded: clockDelta(events, 'window-created', 'did-finish-load'), + totalToWindowCreated: eventTime(events, 'window-created', 'harnessMs'), + totalToDidFinishLoad: eventTime(events, 'did-finish-load', 'harnessMs'), + didFinishLoadToWorkspaceReady: clockDelta( + events, + 'did-finish-load', + 'renderer-startup-hydration-done' + ), + totalToWorkspaceReady: eventTime(events, 'renderer-startup-hydration-done', 'harnessMs'), + rendererReconnectTerminalsMs: + eventDetailsNumber(events, 'renderer-reconnect-terminals-done', 'durationMs') ?? + clockDelta( + events, + 'renderer-first-window-services-await-done', + 'renderer-reconnect-terminals-done' + ), + maxEventLoopStallMs: maxEventDetailsNumber(events, 'event-loop-stall', 'maxGapMs') + } +} diff --git a/tools/benchmarks/startup-time-measurements.test.mjs b/tools/benchmarks/startup-time-measurements.test.mjs new file mode 100644 index 00000000000..caffc212dcc --- /dev/null +++ b/tools/benchmarks/startup-time-measurements.test.mjs @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { deriveStartupPhases, parseStartupLine } from './startup-time-measurements.mjs' + +function event(source, name, t, harnessMs, details = {}) { + return { source, event: name, details: { t, ...details }, harnessMs } +} + +describe('parseStartupLine', () => { + it('parses bootstrap and startup events into the same event schema', () => { + expect( + parseStartupLine( + '[bootstrap] bundle-enter t=4.25 clock="process-performance-now-ms" argv=["Orca App","--flag"]' + ) + ).toEqual({ + source: 'bootstrap', + event: 'bundle-enter', + details: { + t: 4.25, + clock: 'process-performance-now-ms', + argv: ['Orca App', '--flag'] + } + }) + expect( + parseStartupLine( + '[startup] services-initialized service="desktop startup" t=120.5 clock="process-performance-now-ms"' + ) + ).toEqual({ + source: 'startup', + event: 'services-initialized', + details: { + service: 'desktop startup', + t: 120.5, + clock: 'process-performance-now-ms' + } + }) + }) + + it('ignores unrelated stderr', () => { + expect(parseStartupLine('Electron warning')).toBeNull() + }) +}) + +describe('deriveStartupPhases', () => { + it('derives explicit non-negative startup phases on their truthful clocks', () => { + const phases = deriveStartupPhases([ + event('bootstrap', 'bundle-enter', 10, 50), + event('bootstrap', 'bundle-evaluation-complete', 30, 72), + event('startup', 'app-ready', 70, 110), + event('startup', 'services-initialized', 110, 151), + event('startup', 'renderer-first-react-commit', 160, 202, { rendererT: 20 }), + event('startup', 'renderer-shell-painted', 170, 220, { rendererT: 36 }) + ]) + + expect(phases).toMatchObject({ + spawnToBundleEnterMs: 50, + synchronousBundleAndDependencyEvaluationMs: 20, + bundleEvaluationCompleteToAppReadyMs: 40, + appReadyToServicesInitializedMs: 40, + servicesInitializedToFirstReactCommitMs: 50, + firstReactCommitToShellPaintedMs: 16, + totalToFirstReactCommitMs: 202, + totalToShellPaintedMs: 220 + }) + expect(Object.values(phases).filter((value) => typeof value === 'number')).toEqual( + expect.arrayContaining([expect.any(Number)]) + ) + expect( + Object.values(phases) + .filter((value) => typeof value === 'number') + .every((value) => value >= 0) + ).toBe(true) + }) + + it('returns null for phases whose clock boundaries are unavailable', () => { + const phases = deriveStartupPhases([event('bootstrap', 'bundle-enter', 5, 20)]) + + expect(phases.synchronousBundleAndDependencyEvaluationMs).toBeNull() + expect(phases.firstReactCommitToShellPaintedMs).toBeNull() + }) +})